refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hooks-claude/README.md
README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa
README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb
README.md: c97643832821746b816d80d498e8a66fbb9db895
README.zh.md: 0d6cdd60321b1c254c0b36ffc0040fdb1a3f5cb4
+2 -2
View File
@@ -37,7 +37,7 @@ The hooks **themselves** run in the agent's session workspace: for the agent-sco
| CC hook | Harness seam | Mapping |
|---|---|---|
| `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny` → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` (a later listener can still block/rewrite) |
| `UserPromptSubmit` | `agent/pre-step` (waterfall) | `deny` → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision (a later outer listener can still reject/rewrite) |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `deny` → `PreToolDecision.deny`; `ask` → `PreToolDecision.ask` |
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny` → `block` with feedback; additionalContext-only → delegate via `next()` then prepend a separately sourced context to the downstream decision; Code Mode defers sub-call contexts until the outer `run_code` result |
| `Stop` | `agent/turn-stopping` (serial) | a blocking Stop hook feeds its reason through `steer()`, forcing another step |
@@ -52,7 +52,7 @@ Every agent-scoped stdin payload carries `session_id` and string-shaped `transcr
## Context source
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself.
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source so the durable message is never mistaken for a user prompt.
## Model Experience
+2 -2
View File
@@ -37,7 +37,7 @@ hook **本身**会在 agent 的会话工作区中运行:对 agent scope 点,
| CC hook | Harness seam | 映射 |
|---|---|---|
| `SessionStart` | `agent/session-start`(emit) | additionalContext → `agent.inject()` 到新会话(无法阻塞) |
| `UserPromptSubmit` | `agent/prompt-submit`(waterfall,瀑布式事件) | `deny` → `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts`(后续 listener 仍可阻塞/改写) |
| `UserPromptSubmit` | `agent/pre-step`(waterfall,瀑布式事件) | `deny` → `PreStepDecision.reject`;仅 additionalContext → 通过 `next()` 委托,再向下游 `enter` 决策追加一条单独标记来源的消息(后续外层 listener 仍可 reject/改写) |
| `PreToolUse` | `tools/pre-execute`(waterfall) | `deny` → `PreToolDecision.deny`;`ask` → `PreToolDecision.ask` |
| `PostToolUse` | `tools/post-execute`(waterfall) | `deny` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 |
| `Stop` | `agent/turn-stopping`(serial) | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 |
@@ -52,7 +52,7 @@ matcher subject 是工具名称(`PreToolUse`/`PostToolUse`)、会话源(
## 上下文源
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 源。`agent.inject()` 会将缺失源默认为 `{ kind: 'user' }`,这会将插件上下文错误标记为用户提示词,因此桥接始终标注自身。
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-claude' }` 来源,因此持久消息绝不会被误认为用户提示词。
## 模型体验
+10 -18
View File
@@ -12,7 +12,7 @@
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
@@ -197,11 +197,6 @@ export function apply(ctx: Context, config: Config): void {
return [ours, ...theirs ?? []]
}
/** Append hook context to an admitted inbox batch. */
function appendPromptContext(theirs: UserMessage[], ours: UserMessage): UserMessage[] {
return [...theirs, ours]
}
// SessionStart injects context when its detached hook resolves; a slow hook
// may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
@@ -216,26 +211,23 @@ export function apply(ctx: Context, config: Config): void {
}))
})
// --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
// --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise<PromptDecision> => {
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => {
if (messages.length === 0) return next()
const content = messages.flatMap(message => message.content)
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal })
if (merged.decision === 'deny') {
return {
kind: 'block',
reason: merged.reason ?? 'blocked by UserPromptSubmit hook',
discardClaimed: true,
}
return { kind: 'reject' }
}
// Delegate so later listeners may still rewrite or block, then prepend our
// context only to a downstream allow decision.
// Delegate so later listeners may still rewrite or reject, then prepend our
// context only to a downstream enter decision.
const downstream = await next()
const ours = contextFrom(merged)
if (!ours || downstream.kind !== 'allow') return downstream
if (!ours || downstream.kind !== 'enter') return downstream
return {
kind: 'allow',
messages: appendPromptContext(downstream.messages, ours),
kind: 'enter',
messages: [...downstream.messages, ours],
}
})
@@ -89,7 +89,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
}
describe('hooks-claude bridge — UserPromptSubmit', () => {
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
it('a UserPromptSubmit hook that exits 2 rejects step entry without a turn', async () => {
// UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks
// with the reason on stderr.
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
@@ -108,7 +108,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
// The prompt was blocked before the model and before a turn opened.
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
// Admission has no open turn in which turn-scoped hook provenance could live.
// Pre-step has no open turn in which turn-scoped hook provenance could live.
expect(events(agent).some(e => e.type === 'hook/invoked' || e.type === 'hook/result')).toBe(false)
})
@@ -495,10 +495,8 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(path, adapter)
// A later listener that blocks every prompt (registered AFTER the bridge).
ctx.on('agent/prompt-submit', async () => ({
kind: 'block' as const,
reason: 'policy veto',
discardClaimed: true,
ctx.on('agent/pre-step', async () => ({
kind: 'reject' as const,
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
@@ -511,15 +509,15 @@ export function defineCoverageCases(group: CoverageGroup): void {
})
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
// Both the bridge hook and a later prompt-submit listener attach context; the
// Both the bridge hook and a later pre-step listener attach context; the
// request must see both as separately sourced durable events.
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
ctx.on('agent/prompt-submit', async (_agent, messages) => ({
kind: 'allow' as const,
ctx.on('agent/pre-step', async (_agent, messages) => ({
kind: 'enter' as const,
messages: [{
...messages[0]!,
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
@@ -540,8 +538,8 @@ export function defineCoverageCases(group: CoverageGroup): void {
expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
{ kind: 'plugin', plugin: 'hooks-claude' },
])
})