refactor(agent-loop): simplify observable state machine
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { AdditionalContext, Agent, ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
@@ -258,16 +258,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
})
|
||||
|
||||
// A blocking Stop hook forces continuation with its reason.
|
||||
// A blocking Stop hook steers at the stopping boundary, which makes the
|
||||
// machine observe pending input and run another step.
|
||||
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise<ContinuationDecision> => {
|
||||
ctx.on('agent/stopping', async (agent, turn, signal): Promise<void> => {
|
||||
const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal })
|
||||
if (merged.decision === 'deny') {
|
||||
// A blocking Stop hook forces continuation.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
|
||||
agent.steer([{ type: 'text', text }], { source: PLUGIN_SOURCE })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
// SubagentStart may inject child context; SubagentStop only observes. Both
|
||||
|
||||
@@ -58,12 +58,8 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
|
||||
return { ctx, hooks }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
@@ -85,7 +81,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
||||
}
|
||||
|
||||
describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => {
|
||||
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
|
||||
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
|
||||
dirs.push(dir)
|
||||
@@ -100,10 +96,9 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
|
||||
agent.followup([{ type: 'text', text: 'do something' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The prompt was blocked: model never called, turn ended rejected.
|
||||
// The prompt was blocked before the model and before a turn opened.
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('rejected')
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
// The hook ran and was recorded.
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'UserPromptSubmit')).toBe(true)
|
||||
expect(events(agent).some(e => e.type === 'hook/result' && e.data.decision === 'block')).toBe(true)
|
||||
|
||||
@@ -46,8 +46,8 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
|
||||
return agent.whenIdle()
|
||||
}
|
||||
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
|
||||
/** Poll until `predicate` holds or the deadline passes — robust to detached
|
||||
@@ -316,8 +316,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
|
||||
@@ -497,8 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
|
||||
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { AdditionalContext, Agent, ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
@@ -236,11 +236,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
})
|
||||
|
||||
// Stop → ContinuationDecision. A blocking Stop hook forces continuation.
|
||||
// A blocking Stop hook steers at the stopping boundary, which makes the
|
||||
// machine observe pending input and run another step.
|
||||
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
|
||||
// avoid continuing the same turn indefinitely. It is always false here, so an
|
||||
// unconditionally blocking hook force-continues every step until it self-limits.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise<ContinuationDecision> => {
|
||||
ctx.on('agent/stopping', async (agent, turn, signal): Promise<void> => {
|
||||
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal })
|
||||
/* jscpd:ignore-end */
|
||||
if (merged.decision === 'deny') {
|
||||
@@ -248,9 +249,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// empty stderr) still forces it — fall back to a generic steering line
|
||||
// rather than letting the turn stop.
|
||||
const text = merged.reason ?? 'continue: blocked by Stop hook'
|
||||
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
|
||||
agent.steer([{ type: 'text', text }], { source: PLUGIN_SOURCE })
|
||||
}
|
||||
return next()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,8 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
|
||||
return agent.whenIdle()
|
||||
}
|
||||
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
|
||||
|
||||
@@ -125,9 +121,7 @@ describe('hooks-codex bridge', () => {
|
||||
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'aborted' } },
|
||||
})
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(events(agent).some(event => event.type === 'hook/result' && event.data.point === 'UserPromptSubmit')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
|
||||
return agent.whenIdle()
|
||||
}
|
||||
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
|
||||
/** Poll until `predicate` holds or the deadline passes — robust to detached
|
||||
@@ -78,7 +78,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) → rejected turn; default reason on empty stderr', async () => {
|
||||
it('UserPromptSubmit block (exit 2) rejects admission 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')])
|
||||
@@ -86,8 +86,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const te = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected')
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => {
|
||||
@@ -112,8 +111,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
|
||||
const te = events(agent).findLast(e => e.type === 'turn/end')
|
||||
expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {
|
||||
|
||||
Reference in New Issue
Block a user