refactor agent pre-step inbox lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' }` 来源,因此持久消息绝不会被误认为用户提示词。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user