Final Project / 08

Hook & Command 实现

Hook 和 Command 解决的是两个互补的问题:

  • Hook:治理逻辑怎么不散落在业务代码里?怎么让权限、审计、压缩、记忆这些关注点插入执行链,但不和核心逻辑耦合?
  • Command:自然语言适合表达意图,但不适合所有操作。用户要清空会话、跳过某题、切换模型,自然语言解析不如一个确定性命令来得稳。

Part 1: Hook —— 执行链的扩展点

为什么需要 Hook

没有 Hook 的 Agent,Dispatcher 里会充满这样的代码:

// 坏味道:所有治理逻辑混在一起
async function dispatch(toolCall) {
  // 权限检查
  const allowed = await permission.check(toolCall);
  if (!allowed) return blocked();

  // 预算检查
  if (!budget.canAfford(toolCall)) return budgetExceeded();

  // 敏感信息过滤
  toolCall.input = sanitize(toolCall.input);

  const result = await tool.execute(toolCall.input);

  // 审计日志
  audit.log(toolCall, result);

  // 压缩输出
  const compressed = compressor.compress(result);

  // 更新记忆
  if (shouldUpdateMemory(result)) memory.update(result);

  // 更新进度
  session.updateProgress();

  // 指标上报
  metrics.emit(toolCall, result);

  return compressed;
}

每加一个关注点,Dispatcher 就膨胀一层。Hook 把这些逻辑变成独立的、可插拔的单元。

模块结构

src/hooks/
├── pipeline.ts        # HookPipeline 主类
├── types.ts           # Hook 接口定义
├── pre-tool/
│   ├── permission-check.ts
│   ├── input-sanitize.ts
│   └── budget-check.ts
└── post-tool/
    ├── audit-log.ts
    ├── result-compress.ts
    ├── memory-trigger.ts
    ├── progress-update.ts
    └── metric-emit.ts

Hook 接口

// hooks/types.ts

export type HookTiming = 'pre-tool' | 'post-tool';

export interface HookContext {
  toolCall: { name: string; id: string; input: Record<string, unknown> };
  result?: ToolResult;           // post-tool 才有
  session: Session;
  metadata: Map<string, unknown>;  // hook 间传递数据
}

export type HookOutcome =
  | { action: 'continue' }                   // 正常继续
  | { action: 'modify_input'; input: Record<string, unknown> }  // 修改 tool 输入
  | { action: 'modify_result'; result: ToolResult }              // 修改 tool 输出
  | { action: 'block'; reason: string }      // 阻断执行
  | { action: 'skip' };                      // 跳过剩余 hooks

export interface Hook {
  name: string;
  timing: HookTiming;
  priority: number;              // 越小越先执行(0-100)
  enabled: boolean;
  execute(ctx: HookContext): Promise<HookOutcome>;
}

HookPipeline 实现

// hooks/pipeline.ts

import { Hook, HookContext, HookOutcome, HookTiming } from './types';

export class HookPipeline {
  private hooks: Hook[] = [];

  register(hook: Hook): void {
    this.hooks.push(hook);
    this.hooks.sort((a, b) => a.priority - b.priority);
  }

  unregister(name: string): void {
    this.hooks = this.hooks.filter(h => h.name !== name);
  }

  enable(name: string): void {
    const hook = this.hooks.find(h => h.name === name);
    if (hook) hook.enabled = true;
  }

  disable(name: string): void {
    const hook = this.hooks.find(h => h.name === name);
    if (hook) hook.enabled = false;
  }

  async runPre(ctx: HookContext): Promise<HookOutcome> {
    return this.run('pre-tool', ctx);
  }

  async runPost(ctx: HookContext): Promise<HookOutcome> {
    return this.run('post-tool', ctx);
  }

  private async run(timing: HookTiming, ctx: HookContext): Promise<HookOutcome> {
    const applicable = this.hooks.filter(h => h.timing === timing && h.enabled);

    for (const hook of applicable) {
      try {
        const outcome = await hook.execute(ctx);

        switch (outcome.action) {
          case 'continue':
            break;

          case 'modify_input':
            ctx.toolCall.input = outcome.input;
            break;

          case 'modify_result':
            ctx.result = outcome.result;
            break;

          case 'block':
            return outcome;  // 立即终止管线

          case 'skip':
            return { action: 'continue' };  // 跳过剩余 hooks
        }
      } catch (err) {
        // Hook 自身报错不应该阻断主流程
        console.error(`Hook "${hook.name}" error: ${err}`);
        // 继续执行下一个 hook
      }
    }

    return { action: 'continue' };
  }

  list(): Array<{ name: string; timing: HookTiming; priority: number; enabled: boolean }> {
    return this.hooks.map(h => ({
      name: h.name,
      timing: h.timing,
      priority: h.priority,
      enabled: h.enabled,
    }));
  }
}

Pre-tool Hooks 实现

permission-check

// hooks/pre-tool/permission-check.ts

import { Hook, HookContext, HookOutcome } from '../types';
import { PermissionGate } from '../../permission/gate';

export function createPermissionCheckHook(gate: PermissionGate): Hook {
  return {
    name: 'permission-check',
    timing: 'pre-tool',
    priority: 10,   // 最先执行
    enabled: true,

    async execute(ctx: HookContext): Promise<HookOutcome> {
      const decision = await gate.checkTool(
        { name: ctx.toolCall.name, input: ctx.toolCall.input },
        ctx.session.id,
      );

      if (!decision.allowed) {
        return { action: 'block', reason: `Permission denied: ${decision.rule.reason}` };
      }

      // 把决策信息传递给后续 hooks(比如 audit-log)
      ctx.metadata.set('permissionDecision', decision);
      return { action: 'continue' };
    },
  };
}

input-sanitize

// hooks/pre-tool/input-sanitize.ts

import { Hook, HookContext, HookOutcome } from '../types';

export const inputSanitizeHook: Hook = {
  name: 'input-sanitize',
  timing: 'pre-tool',
  priority: 20,
  enabled: true,

  async execute(ctx: HookContext): Promise<HookOutcome> {
    const sanitized = { ...ctx.toolCall.input };
    let modified = false;

    // 过滤可能的敏感信息(用户可配置)
    for (const [key, value] of Object.entries(sanitized)) {
      if (typeof value === 'string') {
        // 脱敏手机号
        const phoneRedacted = value.replace(/1[3-9]\d{9}/g, '***PHONE***');
        // 脱敏邮箱
        const emailRedacted = phoneRedacted.replace(
          /[\w.-]+@[\w.-]+\.\w+/g, '***EMAIL***'
        );

        if (emailRedacted !== value) {
          sanitized[key] = emailRedacted;
          modified = true;
        }
      }
    }

    if (modified) {
      return { action: 'modify_input', input: sanitized };
    }
    return { action: 'continue' };
  },
};

budget-check

// hooks/pre-tool/budget-check.ts

import { Hook, HookContext, HookOutcome } from '../types';

export function createBudgetCheckHook(tokenCounter: TokenCounter): Hook {
  return {
    name: 'budget-check',
    timing: 'pre-tool',
    priority: 30,
    enabled: true,

    async execute(ctx: HookContext): Promise<HookOutcome> {
      const check = tokenCounter.checkBudget();

      if (!check.ok) {
        return {
          action: 'block',
          reason: `Budget exceeded: ${check.reason}. Use /budget to check or /config to increase limit.`,
        };
      }

      return { action: 'continue' };
    },
  };
}

Post-tool Hooks 实现

audit-log

// hooks/post-tool/audit-log.ts

import { Hook, HookContext, HookOutcome } from '../types';
import { AuditLogger } from '../../permission/audit';

export function createAuditLogHook(logger: AuditLogger): Hook {
  return {
    name: 'audit-log',
    timing: 'post-tool',
    priority: 10,   // 最先记录
    enabled: true,

    async execute(ctx: HookContext): Promise<HookOutcome> {
      logger.logToolExecution({
        sessionId: ctx.session.id,
        toolName: ctx.toolCall.name,
        inputSummary: JSON.stringify(ctx.toolCall.input).slice(0, 200),
        outputSummary: ctx.result
          ? JSON.stringify(ctx.result).slice(0, 200)
          : 'no result',
        success: ctx.result?.success ?? false,
        timestamp: new Date().toISOString(),
      });

      return { action: 'continue' };
    },
  };
}

result-compress

// hooks/post-tool/result-compress.ts

import { Hook, HookContext, HookOutcome } from '../types';
import { Compressor } from '../../context/compressor';

export function createResultCompressHook(compressor: Compressor, maxTokens = 1000): Hook {
  return {
    name: 'result-compress',
    timing: 'post-tool',
    priority: 20,
    enabled: true,

    async execute(ctx: HookContext): Promise<HookOutcome> {
      if (!ctx.result?.data) return { action: 'continue' };

      const raw = JSON.stringify(ctx.result.data);
      const estimated = Math.ceil(raw.length / 4);  // 粗估 tokens

      if (estimated <= maxTokens) return { action: 'continue' };

      const compressed = compressor.compressToolOutput(raw, maxTokens);
      return {
        action: 'modify_result',
        result: { ...ctx.result, data: JSON.parse(compressed) },
      };
    },
  };
}

memory-trigger

// hooks/post-tool/memory-trigger.ts

import { Hook, HookContext, HookOutcome } from '../types';
import { MemoryTriggers } from '../../memory/triggers';

export function createMemoryTriggerHook(triggers: MemoryTriggers): Hook {
  return {
    name: 'memory-trigger',
    timing: 'post-tool',
    priority: 30,
    enabled: true,

    async execute(ctx: HookContext): Promise<HookOutcome> {
      // 只在特定 tool 完成后触发记忆写入
      if (ctx.toolCall.name === 'analyze_content' && ctx.result?.success) {
        const diagnosis = ctx.result.data as ContentDiagnosis;
        const question = ctx.toolCall.input.question as string;
        const dimension = ctx.metadata.get('currentDimension') as string;

        triggers.afterSingleQuestion(question, diagnosis, dimension);
      }

      if (ctx.toolCall.name === 'generate_report' && ctx.result?.success) {
        triggers.afterDiagnosis(ctx.result.data as DiagnosisReport);
      }

      return { action: 'continue' };
    },
  };
}

progress-update

// hooks/post-tool/progress-update.ts

import { Hook, HookContext, HookOutcome } from '../types';

export const progressUpdateHook: Hook = {
  name: 'progress-update',
  timing: 'post-tool',
  priority: 40,
  enabled: true,

  async execute(ctx: HookContext): Promise<HookOutcome> {
    // 完成一道题的诊断后更新进度
    if (ctx.toolCall.name === 'analyze_content' && ctx.result?.success) {
      const { done, total } = ctx.session.progress;
      ctx.session.progress = {
        ...ctx.session.progress,
        done: done + 1,
        current: done + 2,
        phase: `诊断中 (${done + 1}/${total})`,
      };
    }

    return { action: 'continue' };
  },
};

metric-emit

// hooks/post-tool/metric-emit.ts

import { Hook, HookContext, HookOutcome } from '../types';

interface MetricPoint {
  tool: string;
  durationMs: number;
  success: boolean;
  timestamp: string;
}

export class MetricCollector {
  private points: MetricPoint[] = [];

  createHook(): Hook {
    const self = this;
    return {
      name: 'metric-emit',
      timing: 'post-tool',
      priority: 50,  // 最后执行
      enabled: true,

      async execute(ctx: HookContext): Promise<HookOutcome> {
        const startTime = ctx.metadata.get('toolStartTime') as number;
        const durationMs = startTime ? Date.now() - startTime : 0;

        self.points.push({
          tool: ctx.toolCall.name,
          durationMs,
          success: ctx.result?.success ?? false,
          timestamp: new Date().toISOString(),
        });

        return { action: 'continue' };
      },
    };
  }

  getSummary(): { totalCalls: number; avgDurationMs: number; successRate: number; byTool: Record<string, { calls: number; avgMs: number }> } {
    const total = this.points.length;
    if (total === 0) return { totalCalls: 0, avgDurationMs: 0, successRate: 0, byTool: {} };

    const avgMs = this.points.reduce((s, p) => s + p.durationMs, 0) / total;
    const successRate = this.points.filter(p => p.success).length / total;

    const byTool: Record<string, { calls: number; avgMs: number }> = {};
    for (const p of this.points) {
      if (!byTool[p.tool]) byTool[p.tool] = { calls: 0, avgMs: 0 };
      byTool[p.tool].calls++;
      byTool[p.tool].avgMs += p.durationMs;
    }
    for (const t of Object.values(byTool)) {
      t.avgMs = Math.round(t.avgMs / t.calls);
    }

    return { totalCalls: total, avgDurationMs: Math.round(avgMs), successRate, byTool };
  }
}

Dispatcher 集成 Hook Pipeline

有了 Hook Pipeline,Dispatcher 变得干净:

// agent/dispatcher.ts

export class Dispatcher {
  private registry: ToolRegistry;
  private hooks: HookPipeline;

  constructor(registry: ToolRegistry, hooks: HookPipeline) {
    this.registry = registry;
    this.hooks = hooks;
  }

  async execute(toolCall: ToolCall, session: Session): Promise<ToolResult> {
    const ctx: HookContext = {
      toolCall: { name: toolCall.name, id: toolCall.id, input: toolCall.input },
      session,
      metadata: new Map([['toolStartTime', Date.now()]]),
    };

    // Pre-tool hooks
    const preOutcome = await this.hooks.runPre(ctx);
    if (preOutcome.action === 'block') {
      return { success: false, error: { code: 'permission_denied', message: preOutcome.reason } };
    }

    // 执行 Tool(用可能被 hook 修改过的 input)
    const tool = this.registry.resolve(ctx.toolCall.name);
    const result = await tool.execute(ctx.toolCall.input, {
      session,
      queryEngine: session.queryEngine,
      knowledgeBase: session.knowledgeBase,
      abortSignal: session.abortController.signal,
    });

    // Post-tool hooks
    ctx.result = result;
    const postOutcome = await this.hooks.runPost(ctx);
    if (postOutcome.action === 'modify_result') {
      return postOutcome.result;
    }

    return ctx.result;
  }
}

Dispatcher 只做两件事:执行 tool + 调用 hooks。所有治理逻辑都在 hooks 里。


Part 2: Command —— 确定性操作入口

为什么需要 Command

自然语言的问题是歧义。“重新开始”是重置当前 session?还是创建新 session?“跳过”是跳过当前题还是跳过当前阶段?

Command 给用户一条确定性通道:输入明确、行为可预测、不经过 LLM 解析。

模块结构

src/command/
├── parser.ts          # CommandParser 主类
├── registry.ts        # 命令注册表
├── types.ts           # 类型定义
└── handlers/
    ├── upload.ts
    ├── diagnose.ts
    ├── status.ts
    ├── skip.ts
    ├── detail.ts
    ├── compare.ts
    ├── report.ts
    ├── history.ts
    ├── mock.ts
    ├── resume.ts
    ├── reset.ts
    ├── export.ts
    ├── budget.ts
    ├── config.ts
    └── help.ts

Command 接口

// command/types.ts

export interface CommandArg {
  name: string;
  description: string;
  required: boolean;
  type: 'string' | 'number' | 'boolean';
  choices?: string[];
}

export interface Command {
  name: string;
  aliases: string[];
  description: string;
  args: CommandArg[];
  examples: string[];
  execute(args: ParsedArgs, session: Session): Promise<CommandResult>;
}

export interface ParsedArgs {
  positional: string[];
  flags: Record<string, string | boolean>;
}

export interface CommandResult {
  output: string;            // 展示给用户的文本
  action?: 'continue' | 'exit' | 'new_session';
  data?: unknown;            // 结构化结果(供后续命令使用)
}

CommandParser 实现

// command/parser.ts

import { Command, ParsedArgs, CommandResult } from './types';

export class CommandParser {
  private commands = new Map<string, Command>();
  private aliasMap = new Map<string, string>();   // alias → command name

  register(cmd: Command): void {
    this.commands.set(cmd.name, cmd);
    for (const alias of cmd.aliases) {
      this.aliasMap.set(alias, cmd.name);
    }
  }

  isCommand(input: string): boolean {
    return input.trimStart().startsWith('/');
  }

  parse(input: string): { command: Command; args: ParsedArgs } | null {
    if (!this.isCommand(input)) return null;

    const trimmed = input.trimStart().slice(1);  // 去掉 /
    const parts = trimmed.split(/\s+/);
    const name = parts[0].toLowerCase();

    // 解析命令名(支持别名)
    const resolvedName = this.aliasMap.get(name) ?? name;
    const command = this.commands.get(resolvedName);
    if (!command) return null;

    // 解析参数
    const args = this.parseArgs(parts.slice(1));

    return { command, args };
  }

  async execute(input: string, session: Session): Promise<CommandResult> {
    const parsed = this.parse(input);
    if (!parsed) {
      return { output: `未知命令。输入 /help 查看可用命令。` };
    }

    try {
      return await parsed.command.execute(parsed.args, session);
    } catch (err) {
      return { output: `命令执行失败: ${err instanceof Error ? err.message : String(err)}` };
    }
  }

  listCommands(): Array<{ name: string; aliases: string[]; description: string }> {
    return Array.from(this.commands.values()).map(c => ({
      name: c.name,
      aliases: c.aliases,
      description: c.description,
    }));
  }

  private parseArgs(parts: string[]): ParsedArgs {
    const positional: string[] = [];
    const flags: Record<string, string | boolean> = {};

    for (let i = 0; i < parts.length; i++) {
      const part = parts[i];
      if (part.startsWith('--')) {
        const key = part.slice(2);
        const next = parts[i + 1];
        if (next && !next.startsWith('--')) {
          flags[key] = next;
          i++;
        } else {
          flags[key] = true;
        }
      } else {
        positional.push(part);
      }
    }

    return { positional, flags };
  }
}

命令实现

/status

// command/handlers/status.ts

export const statusCommand: Command = {
  name: 'status',
  aliases: ['s', 'info'],
  description: '查看当前诊断进度和资源使用',
  args: [],
  examples: ['/status'],

  async execute(args, session): Promise<CommandResult> {
    const { progress } = session;
    const contextStats = session.contextManager.getStats();
    const budgetSummary = session.queryEngine.getUsageSummary();

    const lines: string[] = [
      `Session: ${session.id.slice(0, 8)}  Status: ${session.status}`,
      `Progress: ${progress.done}/${progress.total}  Phase: ${progress.phase}`,
      `${contextStats}`,
      `${budgetSummary}`,
    ];

    if (session.state.mode === 'mock-interview') {
      lines.push(`Mode: 模拟面试  当前第 ${(session.state.currentIndex ?? 0) + 1} 题`);
    }

    return { output: lines.join('\n') };
  },
};

/skip

// command/handlers/skip.ts

export const skipCommand: Command = {
  name: 'skip',
  aliases: [],
  description: '跳过当前题目(或指定编号的题目)',
  args: [{ name: 'n', description: '题目编号(可选)', required: false, type: 'number' }],
  examples: ['/skip', '/skip 5'],

  async execute(args, session): Promise<CommandResult> {
    const qaPairs = session.state.qaPairs as QaPair[] | undefined;
    if (!qaPairs) {
      return { output: '当前没有正在诊断的题目列表。' };
    }

    const targetIndex = args.positional[0]
      ? parseInt(args.positional[0]) - 1
      : session.progress.current - 1;

    if (targetIndex < 0 || targetIndex >= qaPairs.length) {
      return { output: `无效的题号。范围: 1-${qaPairs.length}` };
    }

    // 标记跳过
    if (!session.state.skipped) session.state.skipped = [];
    (session.state.skipped as number[]).push(targetIndex);

    const q = qaPairs[targetIndex].question.slice(0, 50);
    return { output: `已跳过第 ${targetIndex + 1} 题: "${q}..."` };
  },
};

/resume

// command/handlers/resume.ts

export const resumeCommand: Command = {
  name: 'resume',
  aliases: ['r', 'continue'],
  description: '从上次中断处继续诊断',
  args: [{ name: 'session-id', description: 'Session ID(可选,默认最近一次)', required: false, type: 'string' }],
  examples: ['/resume', '/resume abc123'],

  async execute(args, session): Promise<CommandResult> {
    const sessionManager = session.sessionManager;
    const restorer = session.restorer;

    // 找到要恢复的 session
    let targetId: string;
    if (args.positional[0]) {
      targetId = args.positional[0];
    } else {
      const paused = sessionManager.list({ status: 'paused', limit: 1 });
      if (paused.length === 0) {
        return { output: '没有可恢复的会话。' };
      }
      targetId = paused[0].id;
    }

    const restored = await restorer.resume(targetId);
    const { progress } = restored;

    return {
      output: `已恢复会话 ${targetId.slice(0, 8)}\n进度: ${progress.done}/${progress.total}  从第 ${progress.current} 题继续`,
      action: 'continue',
    };
  },
};

/history

// command/handlers/history.ts

export const historyCommand: Command = {
  name: 'history',
  aliases: ['h', 'sessions'],
  description: '查看历史诊断会话',
  args: [{ name: 'limit', description: '显示条数', required: false, type: 'number' }],
  examples: ['/history', '/history 5'],

  async execute(args, session): Promise<CommandResult> {
    const limit = args.positional[0] ? parseInt(args.positional[0]) : 10;
    const sessions = session.sessionManager.list({ limit });

    if (sessions.length === 0) {
      return { output: '暂无历史会话。' };
    }

    const lines = sessions.map(s => {
      const status = { created: '', processing: '', paused: '', completed: '', failed: '' }[s.status];
      return `${status} ${s.id.slice(0, 8)}  ${s.progress.done}/${s.progress.total}${s.inputType}  ${s.updatedAt.slice(0, 10)}`;
    });

    return { output: ['ID        进度    类型        日期', ...lines].join('\n') };
  },
};

/mock

// command/handlers/mock.ts

export const mockCommand: Command = {
  name: 'mock',
  aliases: ['interview', 'practice'],
  description: '开始模拟面试',
  args: [
    { name: 'dimension', description: '维度(可选)', required: false, type: 'string', choices: ['architecture', 'tool', 'memory', 'planning', 'multi-agent', 'engineering', 'rag'] },
    { name: 'count', description: '题数(默认5)', required: false, type: 'number' },
  ],
  examples: ['/mock', '/mock memory', '/mock rag --count 10'],

  async execute(args, session): Promise<CommandResult> {
    const dimension = args.positional[0];
    const count = args.flags.count ? parseInt(args.flags.count as string) : 5;

    const skill = session.skillRegistry.resolve('mock-interview');
    const result = await skill.execute(
      { rawInput: '', parsedArgs: { dimension, count } },
      session.makeSkillContext(),
    );

    return { output: result.report ?? '模拟面试启动失败', action: 'continue' };
  },
};

/export

// command/handlers/export.ts

export const exportCommand: Command = {
  name: 'export',
  aliases: ['save', 'download'],
  description: '导出诊断报告',
  args: [
    { name: 'format', description: '格式', required: false, type: 'string', choices: ['md', 'json', 'pdf'] },
    { name: 'path', description: '输出路径', required: false, type: 'string' },
  ],
  examples: ['/export', '/export md ./report.md', '/export json'],

  async execute(args, session): Promise<CommandResult> {
    const format = (args.positional[0] ?? 'md') as 'md' | 'json' | 'pdf';
    const path = args.positional[1] ?? `./diagnosis-report-${Date.now()}.${format}`;

    const report = session.state.latestReport as DiagnosisReport | undefined;
    if (!report) {
      return { output: '当前会话没有可导出的报告。请先完成诊断。' };
    }

    let content: string;
    switch (format) {
      case 'json':
        content = JSON.stringify(report, null, 2);
        break;
      case 'md':
        content = formatReportAsMarkdown(report);
        break;
      default:
        return { output: 'PDF 导出暂未实现,请使用 md 或 json 格式。' };
    }

    await writeFile(path, content);
    return { output: `报告已导出: ${path} (${format})` };
  },
};

/help

// command/handlers/help.ts

export const helpCommand: Command = {
  name: 'help',
  aliases: ['?', 'commands'],
  description: '显示所有可用命令',
  args: [{ name: 'command', description: '查看某个命令的详细帮助', required: false, type: 'string' }],
  examples: ['/help', '/help mock'],

  async execute(args, session): Promise<CommandResult> {
    const parser = session.commandParser;

    if (args.positional[0]) {
      const parsed = parser.parse(`/${args.positional[0]}`);
      if (!parsed) return { output: `未知命令: ${args.positional[0]}` };

      const cmd = parsed.command;
      const lines = [
        `/${cmd.name}${cmd.aliases.length ? ` (${cmd.aliases.map(a => '/' + a).join(', ')})` : ''}`,
        cmd.description,
        '',
        '参数:',
        ...cmd.args.map(a => `  ${a.name}${a.required ? '' : '?'}  ${a.description}`),
        '',
        '示例:',
        ...cmd.examples.map(e => `  ${e}`),
      ];
      return { output: lines.join('\n') };
    }

    const commands = parser.listCommands();
    const lines = [
      '可用命令:',
      '',
      ...commands.map(c => `  /${c.name.padEnd(12)} ${c.description}`),
      '',
      '输入 /help <command> 查看详细帮助',
    ];
    return { output: lines.join('\n') };
  },
};

命令注册

// command/index.ts

import { CommandParser } from './parser';
import { statusCommand } from './handlers/status';
import { skipCommand } from './handlers/skip';
import { resumeCommand } from './handlers/resume';
import { historyCommand } from './handlers/history';
import { mockCommand } from './handlers/mock';
import { exportCommand } from './handlers/export';
import { helpCommand } from './handlers/help';
// ... 其他命令

export function createCommandParser(): CommandParser {
  const parser = new CommandParser();

  parser.register(statusCommand);
  parser.register(skipCommand);
  parser.register(resumeCommand);
  parser.register(historyCommand);
  parser.register(mockCommand);
  parser.register(exportCommand);
  parser.register(helpCommand);
  // ... 注册其他命令

  return parser;
}

Agent Loop 中的命令拦截

命令必须在进入 LLM 之前被拦截——它不应该被当作自然语言处理。

// agent/loop.ts

async function handleInput(input: string, session: Session): Promise<void> {
  const commandParser = session.commandParser;

  // 1. 命令优先
  if (commandParser.isCommand(input)) {
    const result = await commandParser.execute(input, session);
    output.print(result.output);

    if (result.action === 'exit') process.exit(0);
    if (result.action === 'new_session') await createNewSession();
    return;
  }

  // 2. 否则进入 Agent Loop
  await agentLoop(input, session);
}

完整命令清单

命令             别名              描述
─────────────────────────────────────────────────────────
/upload          -                上传面试稿或录音
/diagnose        /d               开始诊断当前内容
/status          /s, /info        查看进度和资源使用
/skip [n]        -                跳过当前/指定题
/detail [n]      -                查看第 n 题详细诊断
/compare [n]     -                对比第 n 题与高手答
/report          -                生成完整报告
/history         /h, /sessions    查看历史会话
/mock [dim]      /interview       模拟面试
/resume [id]     /r, /continue    恢复中断的会话
/reset           -                清空当前会话
/export [fmt]    /save            导出报告 (md/json)
/budget          -                查看 token 预算
/config          -                查看/修改配置
/hooks           -                查看 hook 状态
/help [cmd]      /?, /commands    帮助信息

Hook × Command × Agent Loop 全景

flowchart TB
    INPUT[用户输入] --> CMD_CHECK{以 / 开头?}

    CMD_CHECK -->|是| CMD_PARSE[CommandParser]
    CMD_PARSE --> CMD_EXEC[执行命令 Handler]
    CMD_EXEC --> OUTPUT[输出结果]

    CMD_CHECK -->|否| SKILL_CHECK{命中 Skill?}
    SKILL_CHECK -->|是| SKILL[执行 Skill 流程]
    SKILL_CHECK -->|否| AGENT_LOOP[Agent Loop]

    AGENT_LOOP --> QE[Query Engine → LLM]
    QE --> PARSE_RESP{响应类型}
    PARSE_RESP -->|text| OUTPUT
    PARSE_RESP -->|tool_use| DISPATCH[Dispatcher]

    DISPATCH --> PRE_HOOK[Pre-tool Hooks]
    PRE_HOOK -->|blocked| OUTPUT
    PRE_HOOK -->|continue| TOOL_EXEC[执行 Tool]
    TOOL_EXEC --> POST_HOOK[Post-tool Hooks]
    POST_HOOK --> AGENT_LOOP

小结

  • Hook Pipeline:pre-tool + post-tool 两个时机,按 priority 顺序执行
  • Hook 可以 continue / modify_input / modify_result / block / skip,Dispatcher 只剩两行核心逻辑
  • Hook 自身报错不阻断主流程(容错设计)
  • 默认 7 个 Hook 覆盖:权限 → 脱敏 → 预算 → 审计 → 压缩 → 记忆 → 进度 → 指标
  • Command:/ 前缀触发,不经过 LLM,参数确定性解析
  • 14 个命令覆盖诊断全生命周期:上传 → 诊断 → 跳过 → 恢复 → 导出
  • 命令在 Agent Loop 之前拦截,不是事后补丁

下一篇建议继续看: