refactor(agent-loop): simplify message machine
This commit is contained in:
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,6 @@ function stubAgent(session: Session): Agent {
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user