Merge remote-tracking branch 'origin/master' into worktree/agent-execution-context-rfc
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/core/README.md # packages/core/agent-loop/README.md # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-calls.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts # website/zh-CN/api/harness/agent-loop.md
This commit is contained in:
@@ -13,11 +13,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -31,12 +35,12 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Resolve on the agent's next idle transition (event-based, not status poll). */
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
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() }
|
||||
@@ -45,7 +49,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
}
|
||||
|
||||
/** All user-message texts recorded in the log (to assert what actually ran). */
|
||||
function userTexts(agent: ReactLoopAgent): string[] {
|
||||
function userTexts(agent: Agent): string[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'user/message')
|
||||
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
|
||||
@@ -56,7 +60,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// The loop is parked at the idle wait with nothing queued. A cancel here must
|
||||
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
|
||||
@@ -73,7 +77,7 @@ describe('Agent.cancel()', () => {
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// send() queues synchronously (status still idle, loop microtask not yet
|
||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
||||
@@ -92,7 +96,7 @@ describe('Agent.cancel()', () => {
|
||||
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('x')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// This waiter cannot rely on a running→idle transition because cancellation
|
||||
// drops the turn before it runs; the skip path must settle it directly.
|
||||
@@ -111,7 +115,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -128,7 +132,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -144,7 +148,7 @@ describe('Agent.cancel()', () => {
|
||||
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
|
||||
const adapter = new MockAdapter(['hang', textResponse('second reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// First turn hangs; cancel it mid-step.
|
||||
send(agent, 'first')
|
||||
@@ -166,7 +170,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Prefix composition runs before the pre-step seam on the instance's first
|
||||
// step; a cancel landing inside it must drop the about-to-start step
|
||||
@@ -201,11 +205,10 @@ describe('Agent.cancel()', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-prefix'),
|
||||
sessionId: SessionId('dispose-prefix-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
@@ -218,7 +221,7 @@ describe('Agent.cancel()', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
// No step opened, no model call ran, and the turn closed disposed.
|
||||
expect(streamed).toBe(false)
|
||||
@@ -230,7 +233,7 @@ describe('Agent.cancel()', () => {
|
||||
it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// The interrupted first composition must not cache its degraded empty value;
|
||||
// the next prompt recomposes and logs/sends the fresh prefix.
|
||||
@@ -260,7 +263,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A turn/start listener fires before a step controller exists, so the
|
||||
// turn-scoped marker—not step abort—must drop the pending step.
|
||||
@@ -287,7 +290,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A step/start session-event listener fires AFTER step/start is appended
|
||||
// (and after the pre-step seam), so cancelling there lands in the SECOND
|
||||
@@ -327,11 +330,10 @@ describe('Agent.cancel()', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-step-start'),
|
||||
sessionId: SessionId('dispose-step-start-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
@@ -342,7 +344,7 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(streamed).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
@@ -359,7 +361,7 @@ describe('Agent.cancel()', () => {
|
||||
// `aborted` and run NO second step.
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -391,7 +393,7 @@ describe('Agent.cancel()', () => {
|
||||
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// `agent/status` is synchronous, so cancellation can land after the first
|
||||
// pre-step check; the second check must drop the now-empty turn.
|
||||
@@ -415,7 +417,7 @@ describe('Agent.cancel()', () => {
|
||||
// Cancellation must not settle idle while replacement work remains queued.
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let replaced = false
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
@@ -442,7 +444,7 @@ describe('Agent.cancel()', () => {
|
||||
// prompt B is queued before the loop resumes from the idle wait.
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'A') // queues A (status still idle, loop microtask pending)
|
||||
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
|
||||
@@ -461,7 +463,7 @@ describe('Agent.cancel()', () => {
|
||||
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
Reference in New Issue
Block a user