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:
Tianyi Cui
2026-07-19 11:44:50 +08:00
295 changed files with 5544 additions and 4182 deletions

View File

@@ -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))