refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions

View File

@@ -43,7 +43,6 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
status: 'running',
ctx: new Context(),
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
@@ -54,6 +53,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
},
send: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle: () => Promise.resolve(),
}
}
@@ -85,7 +85,7 @@ async function fire(
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal)
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
}
function textResponse(text: string): StreamChunk[] {
@@ -294,7 +294,7 @@ describe('durable step context', () => {
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
@@ -363,11 +363,11 @@ describe('real agent-loop request history', () => {
it.each([
['throws', 'error'],
['cancels', 'aborted'],
] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
] as const)('discards the pending preparation reading when a later step listener %s', async (mode, reasonKind) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
let laterSawReading = false
ctx.on('agent/pre-step', (subject) => {
ctx.on('agent/step', (subject) => {
laterSawReading = contextTexts(subject.session).length === 1
if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' })
@@ -377,8 +377,8 @@ describe('real agent-loop request history', () => {
agent.followup([{ type: 'text', text: 'start' }])
await agent.whenIdle()
expect(laterSawReading).toBe(true)
expect(contextTexts(agent.session)).toHaveLength(1)
expect(laterSawReading).toBe(false)
expect(contextTexts(agent.session)).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')

View File

@@ -108,7 +108,9 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(source: unknown): source is WorkspaceInstructionSource {
function isWorkspaceContextSource(
source: unknown,
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'workspace-instructions'
&& 'changes' in source && Array.isArray(source.changes)

View File

@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -178,7 +178,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session,
status: 'idle',
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
@@ -189,6 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
},
send: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle: () => Promise.resolve(),
}
}
@@ -233,11 +233,8 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: A
const composedPrefixes = new WeakMap<object, Message[]>()
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
const empty: Message[] = []
const prefix = await ctx.waterfall(
'agent/session-prefix', agent, empty, AbortSignal.timeout(1000),
() => Promise.resolve(empty),
)
await agentEvents(ctx, agent).serial('agent/step', 1, 1, AbortSignal.timeout(1000))
const prefix = agent.session.deriveMessages()
composedPrefixes.set(agent, prefix)
return prefix
}
@@ -936,7 +933,7 @@ describe('workspace context request injection', () => {
}
})
it('contributes baseline instructions through the frozen session prefix instead of durable history', async () => {
it('contributes baseline instructions through durable injected history', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -948,7 +945,9 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.deriveMessages()).toEqual([])
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(1)
expect(composedPrefixes.get(agent)).toHaveLength(1)
expect(derivedText(agent)).toContain('<system-reminder>')
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
@@ -961,7 +960,7 @@ describe('workspace context request injection', () => {
}
})
it('returns one baseline contribution per session-prefix composition without appending context events', async () => {
it('injects one durable baseline contribution on the first step only', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -975,7 +974,7 @@ describe('workspace context request injection', () => {
const second = await composeBaselinePrefix(ctx, agent)
expect(second).toEqual(first)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
expect(derivedText(agent)).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1005,7 +1004,7 @@ describe('workspace context request injection', () => {
}
})
it('places workspace instructions before later session-prefix contributors such as a skills catalog', async () => {
it('places workspace instructions before later step contributors such as a skills catalog', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1013,9 +1012,10 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
const rest = await next()
return [{ role: 'user', content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }] }, ...rest]
ctx.on('agent/step', (agent) => {
agent.inject([{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], {
source: { kind: 'plugin', plugin: 'test-skills' },
})
})
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
@@ -1147,7 +1147,9 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(1)
expect(derivedText(agent)).not.toContain('workspace-context:')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1270,7 +1272,7 @@ describe('workspace context request injection', () => {
}
})
it('aborts an in-flight baseline stream with the session-prefix signal', async () => {
it('aborts an in-flight baseline stream with the step signal', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
@@ -1282,11 +1284,7 @@ describe('workspace context request injection', () => {
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const controller = new AbortController()
const reason = new Error('cancel prefix')
const empty: Message[] = []
const pending = ctx.waterfall(
'agent/session-prefix', stubAgent(root), empty, controller.signal,
() => Promise.resolve(empty),
)
const pending = agentEvents(ctx, stubAgent(root)).serial('agent/step', 1, 1, controller.signal)
await fs.started.promise
controller.abort(reason)
@@ -1715,14 +1713,16 @@ describe('dynamic nested workspace context injection', () => {
agent.followup([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(0)
agent.followup([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
// The aborted batch drained its accepted context before step close, so the
// retry sees durable history without producing a duplicate instruction.
// Cancellation discards the aborted step's pending context. The next
// successful read discovers and durably injects it once.
expect(contexts).toHaveLength(1)
expect(adapter.requests).toHaveLength(3)
expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n'))