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-codex/README.md
|
||||
README.md: e906810ed58c3d0204c618c32787af06c91cfb78
|
||||
README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334
|
||||
README.md: 60dd1d098966aad6ccdb0957ee223b9843db499f
|
||||
README.zh.md: c84337e9e5b559d5bf7671cc1d1eef429d8fbce5
|
||||
|
||||
@@ -43,7 +43,7 @@ The hooks themselves run in the agent's session workspace: for the agent-scoped
|
||||
| Codex hook | Harness seam | Mapping |
|
||||
|---|---|---|
|
||||
| `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` |
|
||||
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then prepend a separately sourced context to downstream `additionalContexts` |
|
||||
| `UserPromptSubmit` | `agent/pre-step` (waterfall) | `block` (exit 2) → `PreStepDecision.reject`; additionalContext-only → delegate via `next()` then append a separately sourced message to a downstream `enter` decision |
|
||||
| `PreToolUse` | `tools/pre-execute` (waterfall) | `block` → `PreToolDecision.deny` (no `allow`/`ask`) |
|
||||
| `PostToolUse` | `tools/post-execute` (waterfall) | `block` → `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 |
|
||||
@@ -56,7 +56,7 @@ Every agent-scoped stdin payload carries `session_id` and `transcript_path`. The
|
||||
|
||||
## Context source
|
||||
|
||||
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`).
|
||||
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source so the durable message is never mistaken for a user prompt.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ hook 本身会在 agent(智能体)的会话工作区中运行:对 agent sc
|
||||
| Codex hook | Harness seam | 映射 |
|
||||
|---|---|---|
|
||||
| `SessionStart` | `agent/session-start`(emit) | 纯 stdout hook 的输出 → additionalContext → `agent.inject()` |
|
||||
| `UserPromptSubmit` | `agent/prompt-submit`(waterfall,瀑布式事件) | `block`(退出码 2)→ `PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` |
|
||||
| `UserPromptSubmit` | `agent/pre-step`(waterfall,瀑布式事件) | `block`(退出码 2)→ `PreStepDecision.reject`;仅 additionalContext → 通过 `next()` 委托,再向下游 `enter` 决策追加一条单独标记来源的消息 |
|
||||
| `PreToolUse` | `tools/pre-execute`(waterfall) | `block` → `PreToolDecision.deny`(没有 `allow`/`ask`) |
|
||||
| `PostToolUse` | `tools/post-execute`(waterfall) | `block` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游决策;Code Mode 将子调用上下文延迟到外层 `run_code` 结果 |
|
||||
| `Stop` | `agent/turn-stopping`(serial) | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 |
|
||||
@@ -56,7 +56,7 @@ hook 本身会在 agent(智能体)的会话工作区中运行:对 agent sc
|
||||
|
||||
## 上下文源
|
||||
|
||||
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-codex' }` 源(否则 `agent.inject()` 会将其默认为 `{ kind: 'user' }`)。
|
||||
注入上下文携带显式 `{ kind: 'plugin', plugin: 'hooks-codex' }` 来源,因此持久消息绝不会被误认为用户提示词。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -15,7 +15,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'
|
||||
@@ -182,11 +182,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 plain stdout 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.
|
||||
@@ -200,30 +195,27 @@ export function apply(ctx: Context, config: Config): void {
|
||||
/* jscpd:ignore-end */
|
||||
})
|
||||
|
||||
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
|
||||
ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise<PromptDecision> => {
|
||||
// UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask.
|
||||
ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise<PreStepDecision> => {
|
||||
if (messages.length === 0) return next()
|
||||
const payload = {
|
||||
...base(ctx, agent, 'UserPromptSubmit', model),
|
||||
turn_id: String(lastTurn(agent) + 1),
|
||||
turn_id: String(turn),
|
||||
prompt: blocksToText(messages.flatMap(message => message.content)),
|
||||
}
|
||||
const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal })
|
||||
/* jscpd:ignore-start */
|
||||
if (merged.decision === 'deny') {
|
||||
return {
|
||||
kind: 'block',
|
||||
reason: merged.reason ?? 'blocked by UserPromptSubmit hook',
|
||||
discardClaimed: true,
|
||||
}
|
||||
return { kind: 'reject' }
|
||||
}
|
||||
// Context alone is not a veto: DELEGATE so a later prompt-submit listener can
|
||||
// still block/rewrite, then fold our context onto its decision.
|
||||
// Context alone is not a veto: DELEGATE so a later pre-step listener can
|
||||
// still reject/rewrite, then fold our context onto its 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],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
expect((await capture()).payload.transcript_path).toBeNull()
|
||||
}, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom.
|
||||
|
||||
it('UserPromptSubmit block (exit 2) rejects admission without a turn', async () => {
|
||||
it('UserPromptSubmit block (exit 2) rejects step entry without a turn', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('no')])
|
||||
@@ -109,10 +109,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
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' } })); await waitForIdle(ctx, agent)
|
||||
@@ -126,8 +124,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), 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' }],
|
||||
@@ -144,8 +142,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
expect(req).toContain('rewritten-prompt')
|
||||
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-codex' },
|
||||
{ kind: 'plugin', plugin: 'policy' },
|
||||
{ kind: 'plugin', plugin: 'hooks-codex' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user