Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop
# Conflicts: # docs/event-producer-consumer.md # packages/core/agent-loop/README.md # packages/core/agent-loop/tests/contract-regressions.spec.ts
This commit is contained in:
@@ -3,9 +3,8 @@ import { Context } from 'cordis'
|
||||
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
@@ -254,6 +253,190 @@ describe('abort during tool execution ends the turn', () => {
|
||||
expect(adapter.requests).toHaveLength(1) // no follow-up model call
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
|
||||
})
|
||||
|
||||
it('records context accepted before a tool-step abort in the same turn', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: 'accepted result context after abort' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}],
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => event.type))
|
||||
.toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before abort' }],
|
||||
[{ type: 'text', text: 'accepted result context after abort' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('records post-tool context when a later call aborts the batch', async () => {
|
||||
const adapter = new MockAdapter([[
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'first', arguments: '{}' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'aborter', arguments: '{}' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
] satisfies StreamChunk[]])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'first',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return [{ type: 'text', text: 'first done' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
return [{ type: 'text', text: 'aborted' }]
|
||||
},
|
||||
}))
|
||||
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.callId !== CallId('c1')) return next()
|
||||
return {
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: 'accepted after first result' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => event.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events.find(event => event.type === 'context/message')?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'accepted after first result' }])
|
||||
})
|
||||
|
||||
it('drains deferred context before disposal reaches quiescence', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'waiter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
agent.inject([{ type: 'text', text: 'accepted before disposal' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
started.resolve(undefined)
|
||||
const signal = exec.signal
|
||||
if (!signal) throw new Error('tool execution signal is missing')
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) resolve()
|
||||
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: 'accepted result context during disposal' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}],
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await started.promise
|
||||
await fiber.dispose()
|
||||
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before disposal' }],
|
||||
[{ type: 'text', text: 'accepted result context during disposal' }],
|
||||
])
|
||||
expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'disposed' })
|
||||
})
|
||||
|
||||
it('limits injection deferral to the current tool batch', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
[
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
] satisfies StreamChunk[],
|
||||
textResponse('later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'second',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return [{ type: 'text', text: 'must not run' }]
|
||||
},
|
||||
}))
|
||||
|
||||
send(agent, 'leave an unmatched historical call')
|
||||
await waitForIdle(ctx, agent)
|
||||
ctx.on('agent/pre-step', (subject, turn) => {
|
||||
if (subject === agent && turn === 2) {
|
||||
agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
}
|
||||
})
|
||||
send(agent, 'start a text-only turn')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.find(event => event.type === 'context/message')?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'new turn context' }])
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
|
||||
})
|
||||
})
|
||||
|
||||
describe('steering from late extension points is never stranded', () => {
|
||||
|
||||
@@ -414,22 +414,30 @@ describe('agent loop', () => {
|
||||
expect(requestText).not.toContain('<context source=')
|
||||
})
|
||||
|
||||
it('inject() while running appends into the open turn (no extra synthetic turn)', async () => {
|
||||
it('defers inject() during tool execution until after the tool result', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'noticer', {}, 'calling'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// A tool that injects mid-execution: at this point the agent is running, so
|
||||
// inject must append the context/message into the ALREADY-open turn rather
|
||||
// than wrap it in its own one-shot turn.
|
||||
let visibleDuringTool = false
|
||||
const meta = { kind: 'deferred-test', version: 1 }
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noticer',
|
||||
description: 'injects a notice',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
agent.inject([{ type: 'text', text: 'mid-turn notice' }], { source: { kind: 'plugin', plugin: 'x' } })
|
||||
await Promise.resolve()
|
||||
const first = { type: 'text' as const, text: 'mid-turn notice' }
|
||||
agent.inject([first], {
|
||||
source: { kind: 'plugin', plugin: 'x' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
first.text = 'mutated after inject'
|
||||
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
|
||||
visibleDuringTool = agent.session.events.some(e => e.type === 'context/message')
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
@@ -437,13 +445,67 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Exactly ONE turn ran (no synthetic injection turn), and the mid-turn
|
||||
// context/message sits inside it.
|
||||
expect(visibleDuringTool).toBe(false)
|
||||
|
||||
// The injection stays in the open turn, but its user-role context cannot
|
||||
// split the assistant tool call from the provider's tool-result message.
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
const ts0 = turnStarts[0]!
|
||||
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
|
||||
expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
|
||||
const result = agent.session.events.find(e => e.type === 'tool/result')!
|
||||
const contexts = agent.session.events.filter(e => e.type === 'context/message')
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(result.seq).toBeLessThan(contexts[0]!.seq)
|
||||
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'mid-turn notice' },
|
||||
{ type: 'text', text: 'second notice' },
|
||||
])
|
||||
|
||||
const secondRequest = adapter.requests[1]!.messages
|
||||
const resultIndex = secondRequest.findIndex(message =>
|
||||
message.content.some(block => block.type === 'tool-result'))
|
||||
const contextIndexes = secondRequest.flatMap((message, index) =>
|
||||
message.content.some(block => block.type === 'text'
|
||||
&& (block.text.includes('mid-turn notice') || block.text.includes('second notice')))
|
||||
? [index]
|
||||
: [])
|
||||
expect(resultIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(contextIndexes).toHaveLength(2)
|
||||
expect(contextIndexes.every(index => index > resultIndex)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-JSON context before it enters the active tool-batch FIFO', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'invalid-injector', {}, 'calling'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'invalid-injector',
|
||||
description: 'attempts an invalid context injection',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'invalid' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
meta: { bigint: 1n } as never,
|
||||
})
|
||||
}).toThrow('agent context must be losslessly JSON-serializable')
|
||||
return [{ type: 'text', text: 'rejected invalid context' }]
|
||||
},
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
})
|
||||
|
||||
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
|
||||
|
||||
Reference in New Issue
Block a user