Final Project / 07
Permission & Session 实现
Agent 越能干,越需要约束。Permission 决定“能不能做”,Session 决定“做到哪了”。
面试诊断 Agent 处理的是高隐私数据(面试内容、个人表现评估),能调用多种外部 API(STT、LLM),还能写入文件。如果没有权限系统,一个 prompt injection 就可能导致面试内容泄露。如果没有 Session,一次长诊断中断后只能从头来过。
Part 1: Permission —— 安全底座
设计原则
1. 默认拒绝,显式允许
- 没有规则匹配的操作一律拦截
- 新增工具必须同步配置权限规则
2. 风险分级,策略分层
- 低风险:自动放行
- 中风险:首次确认,后续记住
- 高风险:每次确认
3. 可审计
- 每次权限决策(允许/拒绝/确认)都记录日志
- 支持事后追溯
4. 不影响正常流程
- 权限检查 < 1ms(本地规则匹配)
- human-in-the-loop 时清晰告知用户为什么需要确认
模块结构
src/permission/
├── gate.ts # PermissionGate 主类
├── rules.ts # 规则定义 + 默认规则集
├── classifier.ts # 风险分类器
├── confirm.ts # Human-in-the-loop 交互
├── audit.ts # 审计日志
└── types.ts # 类型定义
类型定义
// permission/types.ts
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
export type PermissionAction = 'allow' | 'confirm' | 'deny';
export type ConfirmResult = 'approved' | 'denied' | 'timeout';
export interface PermissionRule {
id: string;
name: string;
match: RuleMatcher;
level: RiskLevel;
action: PermissionAction;
reason: string;
rememberApproval?: boolean; // 用户确认后是否记住
}
export type RuleMatcher =
| { type: 'tool_name'; pattern: string | RegExp }
| { type: 'tool_arg'; tool: string; arg: string; pattern: string | RegExp }
| { type: 'operation'; category: string };
export interface PermissionDecision {
allowed: boolean;
rule: PermissionRule;
confirmedBy?: 'rule' | 'user' | 'remembered';
timestamp: string;
}
export interface AuditEntry {
id: string;
sessionId: string;
toolName: string;
toolArgs: Record<string, unknown>;
decision: PermissionDecision;
timestamp: string;
}
默认规则集
// permission/rules.ts
import { PermissionRule } from './types';
export const DEFAULT_RULES: PermissionRule[] = [
// === 低风险:自动放行 ===
{
id: 'allow-split-qa',
name: '允许拆分Q&A',
match: { type: 'tool_name', pattern: 'split_qa_pairs' },
level: 'low',
action: 'allow',
reason: '纯文本处理,无副作用',
},
{
id: 'allow-analyze-content',
name: '允许内容诊断',
match: { type: 'tool_name', pattern: 'analyze_content' },
level: 'low',
action: 'allow',
reason: 'LLM 分析,不写入外部系统',
},
{
id: 'allow-analyze-speech',
name: '允许语音分析',
match: { type: 'tool_name', pattern: 'analyze_speech' },
level: 'low',
action: 'allow',
reason: '本地计算,无外部调用',
},
{
id: 'allow-knowledge-query',
name: '允许知识库检索',
match: { type: 'tool_name', pattern: 'query_knowledge_base' },
level: 'low',
action: 'allow',
reason: '只读本地数据库',
},
{
id: 'allow-generate-report',
name: '允许生成报告',
match: { type: 'tool_name', pattern: 'generate_report' },
level: 'low',
action: 'allow',
reason: '汇总已有数据,无新副作用',
},
// === 中风险:首次确认,后续记住 ===
{
id: 'confirm-transcribe',
name: 'STT 转写确认',
match: { type: 'tool_name', pattern: 'transcribe_audio' },
level: 'medium',
action: 'confirm',
reason: '将上传音频到外部 STT 服务处理',
rememberApproval: true,
},
{
id: 'confirm-read-file',
name: '读取文件确认',
match: { type: 'tool_name', pattern: 'read_file' },
level: 'medium',
action: 'confirm',
reason: '读取用户本地文件',
rememberApproval: true,
},
// === 高风险:每次确认 ===
{
id: 'confirm-save-memory',
name: '保存记忆确认',
match: { type: 'operation', category: 'memory_write' },
level: 'high',
action: 'confirm',
reason: '将面试相关内容持久化保存',
rememberApproval: false,
},
{
id: 'confirm-export',
name: '导出确认',
match: { type: 'tool_name', pattern: /export|share/ },
level: 'high',
action: 'confirm',
reason: '将诊断报告导出到外部',
rememberApproval: false,
},
// === 关键:始终拒绝 ===
{
id: 'deny-network-unknown',
name: '拒绝未知网络请求',
match: { type: 'operation', category: 'network_unknown' },
level: 'critical',
action: 'deny',
reason: '未识别的外部网络调用',
},
];
PermissionGate 实现
// permission/gate.ts
import { PermissionRule, PermissionDecision, RiskLevel } from './types';
import { DEFAULT_RULES } from './rules';
import { PermissionConfirm } from './confirm';
import { AuditLogger } from './audit';
export class PermissionGate {
private rules: PermissionRule[];
private approvalCache = new Map<string, boolean>(); // 记住用户的确认结果
private confirm: PermissionConfirm;
private audit: AuditLogger;
constructor(
rules?: PermissionRule[],
confirm?: PermissionConfirm,
audit?: AuditLogger,
) {
this.rules = rules ?? DEFAULT_RULES;
this.confirm = confirm ?? new PermissionConfirm();
this.audit = audit ?? new AuditLogger();
}
async checkTool(toolCall: {
name: string;
input: Record<string, unknown>;
}, sessionId: string): Promise<PermissionDecision> {
// 1. 找到匹配的规则
const rule = this.findMatchingRule(toolCall);
if (!rule) {
// 没有规则匹配 → 默认拒绝
const decision: PermissionDecision = {
allowed: false,
rule: { id: 'default-deny', name: '默认拒绝', match: { type: 'tool_name', pattern: '*' }, level: 'critical', action: 'deny', reason: '无匹配规则' },
timestamp: new Date().toISOString(),
};
this.audit.log(sessionId, toolCall, decision);
return decision;
}
// 2. 根据 action 执行策略
let decision: PermissionDecision;
switch (rule.action) {
case 'allow':
decision = { allowed: true, rule, confirmedBy: 'rule', timestamp: new Date().toISOString() };
break;
case 'deny':
decision = { allowed: false, rule, timestamp: new Date().toISOString() };
break;
case 'confirm':
decision = await this.handleConfirm(rule, toolCall);
break;
default:
decision = { allowed: false, rule, timestamp: new Date().toISOString() };
}
// 3. 记录审计日志
this.audit.log(sessionId, toolCall, decision);
return decision;
}
addRule(rule: PermissionRule): void {
this.rules.unshift(rule); // 新规则优先级最高
}
clearApprovalCache(): void {
this.approvalCache.clear();
}
private async handleConfirm(
rule: PermissionRule,
toolCall: { name: string; input: Record<string, unknown> },
): Promise<PermissionDecision> {
// 检查是否已经记住了
const cacheKey = `${rule.id}:${toolCall.name}`;
if (rule.rememberApproval && this.approvalCache.has(cacheKey)) {
return {
allowed: this.approvalCache.get(cacheKey)!,
rule,
confirmedBy: 'remembered',
timestamp: new Date().toISOString(),
};
}
// 向用户发起确认
const result = await this.confirm.ask({
toolName: toolCall.name,
reason: rule.reason,
level: rule.level,
details: this.formatToolDetails(toolCall),
});
const allowed = result === 'approved';
// 记住结果
if (rule.rememberApproval) {
this.approvalCache.set(cacheKey, allowed);
}
return { allowed, rule, confirmedBy: 'user', timestamp: new Date().toISOString() };
}
private findMatchingRule(toolCall: { name: string; input: Record<string, unknown> }): PermissionRule | null {
for (const rule of this.rules) {
if (this.matchesRule(rule, toolCall)) {
return rule;
}
}
return null;
}
private matchesRule(
rule: PermissionRule,
toolCall: { name: string; input: Record<string, unknown> },
): boolean {
const { match } = rule;
switch (match.type) {
case 'tool_name':
return typeof match.pattern === 'string'
? toolCall.name === match.pattern
: match.pattern.test(toolCall.name);
case 'tool_arg':
return toolCall.name === match.tool &&
typeof match.pattern === 'string'
? String(toolCall.input[match.arg]) === match.pattern
: (match.pattern as RegExp).test(String(toolCall.input[match.arg]));
case 'operation':
return false; // operation 类型由调用方显式标记
default:
return false;
}
}
private formatToolDetails(toolCall: { name: string; input: Record<string, unknown> }): string {
const args = Object.entries(toolCall.input)
.map(([k, v]) => ` ${k}: ${String(v).slice(0, 100)}`)
.join('\n');
return `工具: ${toolCall.name}\n参数:\n${args}`;
}
}
Human-in-the-loop 确认
// permission/confirm.ts
import * as readline from 'readline';
import chalk from 'chalk';
export interface ConfirmRequest {
toolName: string;
reason: string;
level: RiskLevel;
details: string;
}
export class PermissionConfirm {
private rl: readline.Interface | null = null;
private timeoutMs: number;
constructor(timeoutMs = 30000) {
this.timeoutMs = timeoutMs;
}
async ask(request: ConfirmRequest): Promise<ConfirmResult> {
const levelColor = {
low: chalk.green,
medium: chalk.yellow,
high: chalk.red,
critical: chalk.bgRed.white,
};
const badge = levelColor[request.level](`[${request.level.toUpperCase()}]`);
console.log(`\n${badge} 权限确认请求`);
console.log(chalk.dim(`原因: ${request.reason}`));
console.log(chalk.dim(request.details));
console.log('');
return new Promise<ConfirmResult>((resolve) => {
const timer = setTimeout(() => {
console.log(chalk.dim('(超时,默认拒绝)'));
resolve('timeout');
}, this.timeoutMs);
this.rl = readline.createInterface({ input: process.stdin, output: process.stdout });
this.rl.question(chalk.bold('允许执行? (y/n): '), (answer) => {
clearTimeout(timer);
this.rl?.close();
resolve(answer.toLowerCase().startsWith('y') ? 'approved' : 'denied');
});
});
}
}
审计日志
// permission/audit.ts
import Database from 'better-sqlite3';
export class AuditLogger {
private db: Database.Database;
constructor(dbPath?: string) {
this.db = new Database(dbPath ?? ':memory:');
this.db.exec(`
CREATE TABLE IF NOT EXISTS permission_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_args TEXT NOT NULL,
rule_id TEXT NOT NULL,
risk_level TEXT NOT NULL,
decision TEXT NOT NULL,
confirmed_by TEXT,
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_session ON permission_audit(session_id);
CREATE INDEX IF NOT EXISTS idx_audit_time ON permission_audit(timestamp);
`);
}
log(sessionId: string, toolCall: { name: string; input: Record<string, unknown> }, decision: PermissionDecision): void {
this.db.prepare(`
INSERT INTO permission_audit (session_id, tool_name, tool_args, rule_id, risk_level, decision, confirmed_by, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
sessionId,
toolCall.name,
JSON.stringify(toolCall.input),
decision.rule.id,
decision.rule.level,
decision.allowed ? 'allowed' : 'denied',
decision.confirmedBy ?? null,
decision.timestamp,
);
}
getSessionLog(sessionId: string): AuditEntry[] {
return this.db.prepare(
'SELECT * FROM permission_audit WHERE session_id = ? ORDER BY timestamp'
).all(sessionId) as AuditEntry[];
}
getStats(): { total: number; allowed: number; denied: number; confirmed: number } {
const row = this.db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN decision = 'allowed' THEN 1 ELSE 0 END) as allowed,
SUM(CASE WHEN decision = 'denied' THEN 1 ELSE 0 END) as denied,
SUM(CASE WHEN confirmed_by = 'user' THEN 1 ELSE 0 END) as confirmed
FROM permission_audit
`).get() as any;
return row;
}
}
Permission 在 Dispatcher 中的位置
flowchart LR
TC[Tool Call] --> GATE{Permission Gate}
GATE -->|allow| EXEC[执行 Tool]
GATE -->|confirm| USER[Human Confirm]
USER -->|approved| EXEC
USER -->|denied| BLOCK[阻断 + 返回原因]
GATE -->|deny| BLOCK
EXEC --> AUDIT[Audit Log]
BLOCK --> AUDIT
Part 2: Session —— 状态持久化与恢复
为什么需要 Session
一场面试诊断涉及 20 道题,每题 3-5 次工具调用,总计 60-100 次模型交互。这个过程可能需要 10-30 分钟。中间任何一个环节中断(网络断开、用户关闭终端、模型限流)都不应该导致从头来过。
Session 保存的是“Agent 做到哪一步了”的完整快照。
模块结构
src/session/
├── manager.ts # SessionManager CRUD
├── state.ts # 状态机定义
├── rewind.ts # 回滚/恢复
├── checkpoint.ts # 检查点管理
└── types.ts # 类型定义
状态机
// session/types.ts
export type SessionStatus = 'created' | 'processing' | 'paused' | 'completed' | 'failed';
export interface Session {
id: string;
status: SessionStatus;
inputType: 'transcript' | 'audio';
inputContent: string; // 原始输入文本(或音频路径)
config: SessionConfig;
progress: SessionProgress;
state: SessionState; // 自定义状态(Skill 写入)
createdAt: string;
updatedAt: string;
abortController: AbortController;
contextManager: ContextManager;
}
export interface SessionConfig {
model: string;
temperature: number;
maxBudget: number; // 美元
preferSkills: boolean;
}
export interface SessionProgress {
total: number; // 总题数
done: number; // 已完成
current: number; // 当前正在处理
phase: string; // 当前阶段描述
}
export interface SessionState {
mode?: string; // 'diagnose' | 'mock-interview' | ...
qaPairs?: QaPair[];
contentDiagnoses?: ContentDiagnosis[];
speechDiagnoses?: SpeechDiagnosis[];
[key: string]: unknown; // Skill 自定义状态
}
export interface SessionCheckpoint {
id: string;
sessionId: string;
progress: SessionProgress;
state: SessionState;
messages: Message[]; // 当时的消息快照
createdAt: string;
}
状态转换
stateDiagram-v2
[*] --> created: 用户发起诊断
created --> processing: Agent 开始工作
processing --> paused: 用户中断 / 超时 / 限流
processing --> completed: 所有题目诊断完成
processing --> failed: 不可恢复错误
paused --> processing: 用户恢复 / 重试
failed --> created: 用户重新开始
SessionManager 实现
// session/manager.ts
import Database from 'better-sqlite3';
import { Session, SessionStatus, SessionCheckpoint } from './types';
import { randomUUID } from 'crypto';
export class SessionManager {
private db: Database.Database;
private activeSessions = new Map<string, Session>();
constructor(dbPath: string) {
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.initSchema();
}
private initSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
input_type TEXT NOT NULL,
input_content TEXT NOT NULL,
config TEXT NOT NULL,
progress TEXT NOT NULL,
state TEXT NOT NULL,
messages TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS checkpoints (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
progress TEXT NOT NULL,
state TEXT NOT NULL,
messages TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id);
`);
}
create(input: { type: 'transcript' | 'audio'; content: string }, config?: Partial<SessionConfig>): Session {
const session: Session = {
id: randomUUID(),
status: 'created',
inputType: input.type,
inputContent: input.content,
config: {
model: config?.model ?? 'claude-sonnet-4-20250514',
temperature: config?.temperature ?? 0,
maxBudget: config?.maxBudget ?? 1.0,
preferSkills: config?.preferSkills ?? true,
},
progress: { total: 0, done: 0, current: 0, phase: '初始化' },
state: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
abortController: new AbortController(),
contextManager: new ContextManager(),
};
this.persist(session);
this.activeSessions.set(session.id, session);
return session;
}
get(id: string): Session | null {
// 先查内存
if (this.activeSessions.has(id)) return this.activeSessions.get(id)!;
// 再查 DB
return this.load(id);
}
list(opts?: { status?: SessionStatus; limit?: number }): SessionSummary[] {
let sql = 'SELECT id, status, input_type, progress, created_at, updated_at FROM sessions';
const params: any[] = [];
if (opts?.status) {
sql += ' WHERE status = ?';
params.push(opts.status);
}
sql += ' ORDER BY updated_at DESC';
if (opts?.limit) {
sql += ' LIMIT ?';
params.push(opts.limit);
}
return this.db.prepare(sql).all(...params).map((row: any) => ({
id: row.id,
status: row.status,
inputType: row.input_type,
progress: JSON.parse(row.progress),
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
updateStatus(id: string, status: SessionStatus): void {
const session = this.get(id);
if (!session) throw new Error(`Session ${id} not found`);
session.status = status;
session.updatedAt = new Date().toISOString();
this.persist(session);
}
updateProgress(id: string, done: number, total: number, phase?: string): void {
const session = this.get(id);
if (!session) return;
session.progress = { ...session.progress, done, total, current: done + 1, phase: phase ?? session.progress.phase };
session.updatedAt = new Date().toISOString();
this.persist(session);
}
updateState(id: string, patch: Partial<SessionState>): void {
const session = this.get(id);
if (!session) return;
session.state = { ...session.state, ...patch };
session.updatedAt = new Date().toISOString();
this.persist(session);
}
save(session: Session): void {
this.persist(session);
}
delete(id: string): void {
this.activeSessions.delete(id);
this.db.prepare('DELETE FROM checkpoints WHERE session_id = ?').run(id);
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
}
private persist(session: Session): void {
this.db.prepare(`
INSERT OR REPLACE INTO sessions (id, status, input_type, input_content, config, progress, state, messages, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
session.id,
session.status,
session.inputType,
session.inputContent,
JSON.stringify(session.config),
JSON.stringify(session.progress),
JSON.stringify(session.state),
JSON.stringify([]), // messages 由 ContextManager 管理,这里存快照
session.createdAt,
session.updatedAt,
);
}
private load(id: string): Session | null {
const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as any;
if (!row) return null;
return {
id: row.id,
status: row.status,
inputType: row.input_type,
inputContent: row.input_content,
config: JSON.parse(row.config),
progress: JSON.parse(row.progress),
state: JSON.parse(row.state),
createdAt: row.created_at,
updatedAt: row.updated_at,
abortController: new AbortController(),
contextManager: new ContextManager(),
};
}
}
Checkpoint:检查点与回滚
// session/checkpoint.ts
import { Session, SessionCheckpoint } from './types';
import Database from 'better-sqlite3';
import { randomUUID } from 'crypto';
export class CheckpointManager {
private db: Database.Database;
private checkpointInterval: number; // 每 N 题存一个检查点
constructor(db: Database.Database, interval = 5) {
this.db = db;
this.checkpointInterval = interval;
}
shouldCheckpoint(session: Session): boolean {
return session.progress.done > 0 &&
session.progress.done % this.checkpointInterval === 0;
}
create(session: Session, messages: Message[]): string {
const id = randomUUID();
this.db.prepare(`
INSERT INTO checkpoints (id, session_id, progress, state, messages, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
`).run(
id,
session.id,
JSON.stringify(session.progress),
JSON.stringify(session.state),
JSON.stringify(messages),
);
return id;
}
list(sessionId: string): SessionCheckpoint[] {
return this.db.prepare(
'SELECT * FROM checkpoints WHERE session_id = ? ORDER BY created_at'
).all(sessionId).map((row: any) => ({
id: row.id,
sessionId: row.session_id,
progress: JSON.parse(row.progress),
state: JSON.parse(row.state),
messages: JSON.parse(row.messages),
createdAt: row.created_at,
}));
}
getLatest(sessionId: string): SessionCheckpoint | null {
const row = this.db.prepare(
'SELECT * FROM checkpoints WHERE session_id = ? ORDER BY created_at DESC LIMIT 1'
).get(sessionId) as any;
if (!row) return null;
return {
id: row.id,
sessionId: row.session_id,
progress: JSON.parse(row.progress),
state: JSON.parse(row.state),
messages: JSON.parse(row.messages),
createdAt: row.created_at,
};
}
rewind(sessionId: string, checkpointId: string): SessionCheckpoint | null {
const checkpoint = this.db.prepare(
'SELECT * FROM checkpoints WHERE id = ? AND session_id = ?'
).get(checkpointId, sessionId) as any;
if (!checkpoint) return null;
// 删除该检查点之后的所有检查点
this.db.prepare(
'DELETE FROM checkpoints WHERE session_id = ? AND created_at > ?'
).run(sessionId, checkpoint.created_at);
return {
id: checkpoint.id,
sessionId: checkpoint.session_id,
progress: JSON.parse(checkpoint.progress),
state: JSON.parse(checkpoint.state),
messages: JSON.parse(checkpoint.messages),
createdAt: checkpoint.created_at,
};
}
}
Session 恢复流程
// session/rewind.ts
export class SessionRestorer {
private sessionManager: SessionManager;
private checkpoints: CheckpointManager;
constructor(sessionManager: SessionManager, checkpoints: CheckpointManager) {
this.sessionManager = sessionManager;
this.checkpoints = checkpoints;
}
async resume(sessionId: string): Promise<Session> {
const session = this.sessionManager.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
if (session.status === 'completed') throw new Error('Session already completed');
// 从最近的检查点恢复
const checkpoint = this.checkpoints.getLatest(sessionId);
if (checkpoint) {
// 恢复状态
session.progress = checkpoint.progress;
session.state = checkpoint.state;
// 恢复 Context(从 checkpoint messages 重建)
session.contextManager = new ContextManager();
for (const msg of checkpoint.messages) {
session.contextManager.addMessage(msg);
}
console.log(`Resumed from checkpoint: ${checkpoint.progress.done}/${checkpoint.progress.total} done`);
} else {
// 没有检查点——从头开始但保留已有诊断结果
console.log('No checkpoint found, resuming from session state');
}
// 标记为 processing
session.status = 'processing';
this.sessionManager.save(session);
return session;
}
async rewindTo(sessionId: string, checkpointId: string): Promise<Session> {
const checkpoint = this.checkpoints.rewind(sessionId, checkpointId);
if (!checkpoint) throw new Error('Checkpoint not found');
const session = this.sessionManager.get(sessionId)!;
session.progress = checkpoint.progress;
session.state = checkpoint.state;
session.status = 'processing';
session.contextManager = new ContextManager();
for (const msg of checkpoint.messages) {
session.contextManager.addMessage(msg);
}
this.sessionManager.save(session);
console.log(`Rewound to checkpoint ${checkpointId}: ${checkpoint.progress.done}/${checkpoint.progress.total}`);
return session;
}
}
在 Agent Loop 中的集成
// agent/loop.ts 中 Session 相关逻辑
async function agentLoop(input: string, session: Session): Promise<void> {
session.status = 'processing';
sessionManager.save(session);
try {
// ... 正常 loop 逻辑 ...
// 每完成一题检查是否需要检查点
if (checkpointManager.shouldCheckpoint(session)) {
const messages = session.contextManager.getRecentMessages();
checkpointManager.create(session, messages);
}
session.status = 'completed';
} catch (err) {
if (isRetryable(err)) {
session.status = 'paused';
console.log(`Session paused: ${err.message}. Use /resume to continue.`);
} else {
session.status = 'failed';
console.error(`Session failed: ${err.message}`);
}
} finally {
sessionManager.save(session);
}
}
中断恢复的用户体验
场景: 用户正在诊断 20 道题,诊断到第 12 题时网络断开
[正常诊断中]
✓ Q1 ... 75分
✓ Q2 ... 62分
...
✓ Q11 ... 81分
● Q12 诊断中...
✗ 网络错误: API timeout
Session 已暂停 (12/20)。输入 /resume 继续。
[用户稍后回来]
> /resume
从检查点恢复: 第 10 题已保存
跳过 Q1-Q10(已有诊断结果)
继续从 Q11 开始...
● Q11 诊断中...
✓ Q11 ... 81分
● Q12 诊断中...
...
Permission × Session 协作
flowchart TB
subgraph "Session 生命周期"
CREATE[创建 Session] --> PROC[Processing]
PROC --> CP{检查点?}
CP -->|每5题| SAVE[保存 Checkpoint]
SAVE --> PROC
PROC --> DONE[Completed]
PROC --> PAUSE[Paused]
PAUSE --> RESUME[Resume]
RESUME --> PROC
end
subgraph "Permission 检查"
TOOL_CALL[Tool Call] --> GATE[Permission Gate]
GATE --> AUDIT[Audit Log]
end
PROC --> TOOL_CALL
GATE -->|denied| PAUSE
AUDIT --> SESSION_DB[(Session DB)]
SAVE --> SESSION_DB
当 Permission 拒绝一个关键 Tool(比如用户拒绝 STT 调用),Session 自动进入 paused 状态,而不是 failed——因为用户可能只是暂时不想授权,稍后可以恢复。
小结
- Permission:默认拒绝 + 风险分级(low/medium/high/critical),中风险支持“记住”
- human-in-the-loop 通过 CLI readline 实现,超时默认拒绝
- 所有权限决策记录审计日志,支持事后追溯
- Session:完整状态机(created → processing → paused/completed/failed)
- 检查点每 5 题自动保存,支持回滚到任意检查点
- 中断恢复:从最近检查点加载状态,跳过已完成的题目
- Permission 拒绝导致 Session 暂停而非失败——可恢复
下一篇建议继续看: