feat: add PostTurn hook event for turn-level post-processing#3266
feat: add PostTurn hook event for turn-level post-processing#3266zhangxy-zju wants to merge 8 commits intomainfrom
Conversation
New PostTurn hook event fires at each model turn boundary (tool_call or end of response). Hook receives accumulated thoughts + messages, and can return an acpMessage to inject into the ACP stream with _meta.rewritten: true. Core changes: - types.ts: PostTurn enum, PostTurnInput, PostTurnHookOutput with getAcpMessage() method - hookEventHandler.ts: firePostTurnEvent() - hookSystem.ts: public firePostTurnEvent() - hookAggregator.ts: PostTurn fire-and-forget (preserve outputs, ignore errors) - toolHookTriggers.ts: firePostTurnHook() via MessageBus ACP integration: - Session.ts: accumulate turn content during streaming, fire-and-forget PostTurn hook at turn boundaries, inject acpMessage via sendUpdate
- previous_rewrites → previous_hook_outputs (generic) - rewriteHistory → hookOutputHistory (generic) - _meta.rewritten → _meta.postTurnHook (framework-level marker)
…ough - Add acpMeta field to PostTurn hook output (hookSpecificOutput.acpMeta) - PostTurnHookOutput.getAcpMeta() extracts it - Session.ts passes acpMeta as-is to ACP message _meta - Remove hardcoded _meta.postTurnHook — hook scripts decide metadata
PostTurnInput should only carry current turn data, not track hook output history. Scripts that need context across turns should manage their own state (e.g. temp files). This keeps the hook interface generic and avoids coupling ACP message injection semantics into the core hook protocol.
Replace boolean has_tool_calls with tool_calls array containing name and args for each function call in the turn. This gives hook scripts full visibility into what tools were invoked, enabling richer processing (e.g. filtering by tool type, logging tool usage).
P1: Fire PostTurn hook at every turn boundary, not just when
thoughts/messages are present. Pure tool_call turns now trigger.
P1: Fix protocol field inconsistency — docs updated from
has_tool_calls to tool_calls array (matching implementation).
P2: Add PostTurn to settings schema and UI hook constants
(description, exit codes, detail text).
P2: Multi-hook aggregation: first non-empty acpMessage wins,
explicitly documented in hookAggregator.
📋 Review SummaryThis PR introduces a new 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
|
Hey @zhangxy-zju Thanks for the PR! Quick note — the existing hook framework already has Assigning @DennisYu07 to review since he's been leading the hook system work (#2827, #3248) and can best assess how this fits the architecture. |
Agree with this, personally I think it is a little redundant. |
Explain the feature purpose (business-oriented output customization), mark it as a temporary solution, and reference the hook-based alternative (#3266) for future discussion.
…3191) * feat(acp): LLM-based message rewrite middleware Add MessageRewriteMiddleware that intercepts ACP messages and appends LLM-rewritten versions with _meta.rewritten=true at turn boundaries. Original messages pass through unmodified. At the end of each turn (before tool calls or at response end), accumulated thought/message chunks are sent to LLM for rewriting into business-friendly text. - TurnBuffer: accumulates chunks per turn - LlmRewriter: calls LLM with configurable prompt - MessageRewriteMiddleware: orchestrates intercept → buffer → rewrite → emit - BaseEmitter.sendUpdate: routes through middleware when configured - Session: initializes middleware from settings.messageRewrite config Enable via settings.json: { "messageRewrite": { "enabled": true, "target": "both", "prompt": "custom system prompt for rewriter" } } Rewritten messages carry _meta.rewritten=true for frontend to prioritize display. Original messages remain for debugging. * fix: TypeScript 编译错误修复 + 优化默认改写 prompt(参考竞品风格) * fix: 从 user/workspace originalSettings 读取 messageRewrite 配置(绕过 schema 校验) * feat: 非交互 CLI 模式也支持 message rewrite(eval 可用) * fix: 禁用 rewriter LLM 的 thinking,过滤 thought 部分只取纯文本输出 * fix: cron 路径补齐 message rewrite flush + 代码质量优化 - Session.ts cron 路径添加 messageRewriter.flushTurn() 调用 - nonInteractiveCli.ts cron 路径添加 turnBuffer 累积 + flush + rewrite - 提取 loadRewriteConfig() 共享函数,消除两处重复配置读取 - 主路径和 cron 路径添加 turnBuffer.markToolCall() - rewrite 调用添加 30s 超时保护(AbortSignal.timeout) - 修复 import 语句被 const 声明分割的问题 * feat: rewrite 支持 async/sync 模式(默认 async,不增加执行时间) * feat: rewrite prompt 通用化 + 上下文连贯 + promptFile + async 修复 - 默认 prompt 改为通用英文版(适配任意 coding agent,不绑定数据分析场景) - 支持 promptFile 配置项,从文件加载自定义 prompt(优先于 inline prompt) - 上下文连贯性:lastOutput 记录上一轮改写结果,拼接到下一轮输入, 避免连续 turn 间信息重复 - 修复 CLI 非交互模式 async rewrite 丢失:void doRewrite() 改为 pendingRewrites 数组 + emitResult 前 Promise.allSettled - 增加 debug logging:REWRITE INPUT/OUTPUT 完整内容 + prev_output 长度 * refactor: remove sync rewrite mode, always use async (non-blocking) rewrite - Remove `async` field from MessageRewriteConfig - MessageRewriteMiddleware.flushTurn() always fires in background - nonInteractiveCli.ts main & cron paths always push to pendingRewrites - No user-facing latency from rewrite calls * fix: address review feedback — trust check, timeout, history replay 1. loadRewriteConfig: skip workspace settings when !isTrusted, preventing untrusted repos from enabling rewriter with a custom prompt 2. MessageRewriteMiddleware.flushTurn: always enforce 30s timeout internally, even when caller provides no AbortSignal (interactive path) 3. Install rewriter AFTER history replay completes (Session.installRewriter), so historical messages are never rewritten on session load * fix: address second round review — target filter, timeout, rewrite queue 1. nonInteractiveCli: apply rewriteConfig.target filter to accumulation (main path and cron path), matching MessageRewriteMiddleware behavior 2. nonInteractiveCli: add 30s AbortSignal.timeout to rewrite calls in both main and cron paths 3. MessageRewriteMiddleware: replace single pendingRewrite slot with pendingRewrites array + Promise.allSettled, ensuring all rewrites complete before session exits * test: add unit tests for TurnBuffer, loadRewriteConfig, MessageRewriteMiddleware - TurnBuffer: flush, reset, isEmpty, markToolCall, whitespace filtering (12 tests) - loadRewriteConfig: isTrusted gating, workspace/user precedence (5 tests) - MessageRewriteMiddleware: target filtering, tool_call boundary flush, pendingRewrites queue, rewrite metadata (9 tests) * fix: config.test.ts use unknown cast for LoadedSettings stub (fix tsc --build) * fix: filter LLM literal "empty string" responses in rewriter output LLM sometimes outputs "(空字符串)" or similar text instead of actual empty string when instructed to "return empty string". Add regex patterns to catch common variants and treat them as null (skip rewrite output). * revert: remove LLM empty-string pattern defense, rely on prompt fix instead * fix: prevent async rewrite from corrupting adapter state + honor config.model 1. nonInteractiveCli: rewrite promises now return data only, adapter emission happens synchronously via emitSettledRewrites() at safe boundaries (before next turn starts, before cron next turn, before final result). Prevents concurrent startAssistantMessage corruption. 2. LlmRewriter: use rewriteConfig.model when set, fallback to config.getModel(). Previously model field was defined but ignored. * docs: add messageRewrite configuration guide to settings.md * Revert "docs: add messageRewrite configuration guide to settings.md" This reverts commit ecd57e2. * feat: add contextTurns config for rewrite history context Allow configuring how many previous rewrite outputs are included as context when rewriting a new turn: - contextTurns: 1 (default) = last rewrite only - contextTurns: 0 = no context - contextTurns: N = last N rewrites - contextTurns: "all" = all previous rewrites * refactor: rename target 'both' to 'all' + add LlmRewriter unit tests - Rename target value 'both' → 'all' for future extensibility (e.g. 'tool') - Add LlmRewriter tests: contextTurns (0/1/N/all), model override, filtering - Total: 35 tests across 4 test files * refactor: remove message rewrite from non-interactive CLI mode Non-interactive mode (qwen -p "..." --output-format json) consumers are scripts/programs that don't need user-friendly rewrites. Additionally, the JSON output adapter doesn't support _meta fields, so rewritten text was silently mixed into normal assistant messages without any marker. Rewrite middleware is now ACP-only (Session path). * revert: restore package-lock.json and nonInteractiveCli.ts to main state * docs: add README for message rewrite middleware Explain the feature purpose (business-oriented output customization), mark it as a temporary solution, and reference the hook-based alternative (#3266) for future discussion. * docs: move temporary-solution notice to top of README * docs: simplify temporary-solution notice in rewrite README
|
Closing for now — turn-level post-processing is currently supported via the ACP message rewrite middleware (#3191) as a temporary solution. We can revisit the hook-based approach when the middleware no longer meets our needs. |
Summary
Add a new
PostTurnhook event that fires at every model turn boundary (tool_call or end of response). Hook scripts receive the turn's accumulated thoughts, messages, and tool calls, and can optionally inject a message into the ACP stream with custom metadata.Closes #3265
Changes (10 files, +319 lines)
Core hook framework (
packages/core):types.ts: NewPostTurnenum,PostTurnInput,PostTurnOutput,PostTurnHookOutputclass withgetAcpMessage()/getAcpMeta()hookEventHandler.ts:firePostTurnEvent()hookSystem.ts: PublicfirePostTurnEvent()hookAggregator.ts: PostTurn fire-and-forget handling (preserve outputs, ignore errors, first non-empty acpMessage wins)toolHookTriggers.ts:firePostTurnHook()via MessageBusconfig.ts: PostTurn case in MessageBus hook dispatcherACP integration (
packages/cli):Session.ts: Accumulate turn content during streaming, fire-and-forget PostTurn hook at every turn boundary, pass throughacpMessage+acpMetato ACP streamsettingsSchema.ts: PostTurn in hooks schemahooks/constants.ts: PostTurn descriptions and exit codesHook Protocol
Input (stdin JSON):
{ "turn_index": 3, "thoughts": ["..."], "messages": ["..."], "tool_calls": [{ "name": "read_file", "args": { "file_path": "..." } }] }Output (stdout JSON):
{ "decision": "allow", "hookSpecificOutput": { "hookEventName": "PostTurn", "acpMessage": "Message to inject into ACP stream", "acpMeta": { "custom": "metadata passed through as _meta" } } }Configuration
{ "hooks": { "PostTurn": [{ "hooks": [{ "type": "command", "command": "python3 .qwen/hooks/my_script.py", "timeout": 30000 }] }] } }Design Decisions
acpMetadefined by hook scripts, framework passes through as-isacpMessagewinsPostTurnhook config with zero side effectsTest plan