refactor(agent-loop): simplify message machine

This commit is contained in:
_Kerman
2026-07-30 13:49:57 +08:00
parent d554ae3019
commit f2e20c1ef0
212 changed files with 1326 additions and 2382 deletions

View File

@@ -9,7 +9,7 @@ import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement,
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
@@ -474,38 +474,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
* inbox event retires one matching occurrence, so repeated sends of the same
* identified message remain visible until every occurrence is claimed.
*/
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean }[]>()
const queuedMirror = new Map<SessionId, UserMessage[]>()
ctx.effect(() => {
const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => {
const retire = (agent: Agent, id: MessageId): void => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) return
const index = entries.findIndex(entry =>
entry.message.id === id
&& (placement === undefined || entry.steering === (placement === 'steering')))
const index = entries.findIndex(message => message.id === id)
if (index !== -1) entries.splice(index, 1)
if (entries.length === 0) queuedMirror.delete(agent.id)
}
const disposers = [
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'agent/inbox/added') return
const agent = ctx.agents.get(session.id)
if (agent === undefined || agent.session !== session) return
const message = event.data
let entries = queuedMirror.get(agent.id)
if (entries === undefined) {
entries = []
queuedMirror.set(agent.id, entries)
}
const steering = placement === 'steering'
entries.push({ message, steering })
entries.push(message)
broadcast({
type: 'session/queued',
sessionId: agent.id,
message,
steering,
})
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => {
retire(agent, message.id, placement)
ctx.on('agent/inbox/admitted', (agent: Agent, message: UserMessage) => {
retire(agent, message.id)
}),
ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => {
for (const message of messages) retire(agent, message.id)
ctx.on('agent/inbox/canceled', (agent: Agent, message: UserMessage) => {
retire(agent, message.id)
}),
ctx.on('session/disposed', (session: Session) => {
queuedMirror.delete(session.id)
@@ -1338,12 +1338,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// in arrival order per session; a reconnecting client rebuilds its
// queue view from these alone.
for (const [sessionId, entries] of queuedMirror) {
for (const entry of entries) {
for (const message of entries) {
queue.push(frame({
type: 'session/queued',
sessionId,
message: entry.message,
steering: entry.steering,
message,
}))
}
}

View File

@@ -42,7 +42,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
// and must fail loud here, not reach the composer.
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }),
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema }),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),

View File

@@ -68,12 +68,10 @@ export type MuxFrame =
* host replays the current queue snapshot for every attached session (same
* refresh-recovery baseline as pending questions); queue clearing on cancel
* has no dedicated frame — clients fold it from the status flip.
* `steering` is the host's acceptance-time queue classification and remains
* authoritative in reconnect snapshots. `message.source` carries the prompt's rpcId
* when the message came over this wire (the client's provisional-echo
* reconciliation key).
* `message.source` carries the prompt's rpcId when the message came over
* this wire (the client's provisional-echo reconciliation key).
*/
| { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean }
| { type: 'session/queued'; sessionId: SessionId; message: Message }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the

View File

@@ -71,7 +71,7 @@ describe('summary blank = conversation not started', () => {
const session = ctx.sessions.create()
attach(session)
appendStandalone(session)
session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 0 })
expect(await listBlank(api, session.id)).toBe(false)
})
})

View File

@@ -225,7 +225,7 @@ describe('session/projection push frame', () => {
seedMessages(session, 1)
// Same-reference apply: turn/start does not concern the unit — no frame.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
seedMessages(session, 1)
const frames = await collected

View File

@@ -83,7 +83,7 @@ describe('mux live view computation', () => {
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
@@ -146,7 +146,7 @@ describe('mux live view computation', () => {
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', {
@@ -217,7 +217,7 @@ describe('mux live view computation', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('session-doomed' as SessionId)
}, { inject: ['sessions'] }))
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session?.append('turn/start', { turn: 1 })
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
// Disposing the owning fiber detaches the session mid-stream; the
// session/disposed listener must clear its open-call table entry.
@@ -236,7 +236,7 @@ describe('mux live view computation', () => {
const collected = collect(stream, 4, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// The turn/end above cleared the live table; pairing must fall back to

View File

@@ -50,7 +50,6 @@ function stubAgent(session: Session): Agent {
followup: () => {},
steer: () => {},
inject: () => {},
send: () => {},
cancel() {},
whenIdle: () => Promise.resolve(),
}