Fix architecture-review findings in the loop and service packages

High (loop pipeline): agent/step-result now runs before the
assistant/message append so the session log records what tool dispatch
actually uses; abort is honored between tool calls, not just
mid-stream; steering drains at step start, pending steering overrides
a negative turn-continuation decision (/goal pattern), and leftover
steering is re-enqueued as queued messages so it is never stranded;
exceptions from turn-continuation listeners and session/flush are
contained to the turn (error event + agent/error) instead of killing
the driver loop.

Medium: disposal emits agent/status('disposed') and mid-turn disposal
records reason 'disposed'; duplicate LLM adapter registration throws
(all-or-nothing); SessionEvent is a real discriminated union (casts
removed); model-less agents fail with a clear actionable error unless
agent/request supplies a model.

Low: agent/queued and agent/steering carry the resolved MessageSource;
streamBlocks() yields strictly in stream order and flushes delta-only
blocks (matches generate()); BlockAssembler freezes blocks on
block-end and ignores stragglers from malformed streams; turn
numbering is a counter seeded from the log (fork-safe); LoopAgent's
stop disposer is infallible (a throwing status listener cannot skip
registry cleanup); AgentLoop.create uses a generator effect so stop
and unregister are independent disposables; SessionStore wires
onAppend inside its effect.

21 regression tests added (review-fixes.spec.ts), organized by
finding. Docs updated: loop pseudocode (status emissions, ordering,
error containment, steering guarantees) and waterfall composition
caveat in docs/architecture.md; AGENTS.md notes that excessive tests
are welcome.
This commit is contained in:
Tianyi Cui
2026-06-11 12:18:52 +08:00
parent cacfae3cae
commit 217b8ec0e2
12 changed files with 887 additions and 168 deletions

View File

@@ -1,7 +1,7 @@
import type { Context } from 'cordis'
import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { Inbox } from './inbox.ts'
import { runLoop } from './loop.ts'
@@ -44,25 +44,28 @@ export class LoopAgent implements Agent {
this.ctx.emit('agent/status', this, status)
}
private resolveSource(options?: SendOptions): MessageSource {
return options?.source ?? { kind: 'user' }
}
send(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = options?.source ?? { kind: 'user' as const }
const source = this.resolveSource(options)
this.inbox.enqueue({ content, source })
this.ctx.emit('agent/queued', this, content, { ...options, steering: false })
this.ctx.emit('agent/queued', this, content, { source, steering: false })
}
steer(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
if (this._status !== 'running') return this.send(content, options)
const source = options?.source ?? { kind: 'user' as const }
const source = this.resolveSource(options)
this.inbox.steer({ content, source })
this.ctx.emit('agent/queued', this, content, { ...options, steering: true })
this.ctx.emit('agent/queued', this, content, { source, steering: true })
}
inject(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
const source = options?.source ?? { kind: 'user' as const }
this.session.append('context/message', { content, source })
this.session.append('context/message', { content, source: this.resolveSource(options) })
}
abort(reason?: string): void {
@@ -77,10 +80,22 @@ export class LoopAgent implements Agent {
disposed: this.disposed,
isDisposed: () => this._status === 'disposed',
})
// The disposer must be infallible: it runs inside the fiber's LIFO
// disposal chain, where a throw would skip later disposers (e.g. the
// registry unregistration) and leave `done` pending forever.
return () => {
if (this._status === 'disposed') return
this._status = 'disposed'
this.resolveDisposed()
this.currentAbort?.abort('disposed')
// setStatus refuses transitions out of 'disposed', so emit directly —
// 'disposed' is part of the agent/status contract. Guarded: a throwing
// listener must not break the disposal chain.
try {
this.ctx.emit('agent/status', this, 'disposed')
} catch {
// listener error during disposal — nothing safe left to do with it
}
}
}
}