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

@@ -66,9 +66,9 @@ export class Session {
/** Append one event. Synchronous — the hot path never blocks on I/O. */
append<T extends SessionEventType>(type: T, data: SessionEventMap[T]): SessionEvent<T> {
const event: SessionEvent<T> = { type, seq: this.log.length, time: Date.now(), data }
this.log.push(event as SessionEvent)
this.onAppend?.(event as SessionEvent)
const event = { type, seq: this.log.length, time: Date.now(), data } as SessionEvent<T>
this.log.push(event)
this.onAppend?.(event)
return event
}
@@ -87,17 +87,15 @@ export class Session {
for (const event of this.log) {
switch (event.type) {
case 'user/message': {
const { content } = event.data as SessionEventMap['user/message']
messages.push({ role: 'user', content })
messages.push({ role: 'user', content: event.data.content })
break
}
case 'assistant/message': {
const { content } = event.data as SessionEventMap['assistant/message']
messages.push({ role: 'assistant', content })
messages.push({ role: 'assistant', content: event.data.content })
break
}
case 'tool/result': {
const { callId, content, isError } = event.data as SessionEventMap['tool/result']
const { callId, content, isError } = event.data
messages.push({
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
@@ -105,12 +103,12 @@ export class Session {
break
}
case 'context/message': {
const { content, source } = event.data as SessionEventMap['context/message']
const { content, source } = event.data
messages.push({ role: 'user', content: renderTagged('context', content, source) })
break
}
case 'steering/message': {
const { content, source } = event.data as SessionEventMap['steering/message']
const { content, source } = event.data
messages.push({ role: 'user', content: renderTagged('steering', content, source) })
break
}
@@ -139,8 +137,8 @@ export class SessionStore extends Service {
id ??= `session-${++this.counter}`
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
const session = new Session(id, seed)
session.onAppend = (event) => this.ctx.emit('session/event', session, event)
this.ctx.effect(() => {
session.onAppend = (event) => this.ctx.emit('session/event', session, event)
this.store.set(id, session)
this.ctx.emit('session/created', session)
return () => {

View File

@@ -63,12 +63,19 @@ export interface SessionEventMap {
export type SessionEventType = keyof SessionEventMap
/** One immutable entry in the session log. */
export interface SessionEvent<T extends SessionEventType = SessionEventType> {
type: T
/** Monotonic sequence number within the session. */
seq: number
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[T]
}
/**
* One immutable entry in the session log.
*
* A proper discriminated union over `type` (not independent `type`/`data`
* unions), so `switch (event.type)` narrows `event.data` without casts.
*/
export type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
type: K
/** Monotonic sequence number within the session. */
seq: number
/** Unix epoch milliseconds. */
time: number
data: SessionEventMap[K]
}
}[T]