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:
@@ -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 () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user