refactor(agent-loop): simplify message machine
This commit is contained in:
@@ -18,7 +18,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
case 'max-tokens':
|
||||
return 'max_tokens'
|
||||
case 'aborted':
|
||||
case 'disposed':
|
||||
case 'interrupted':
|
||||
return 'cancelled'
|
||||
case 'error':
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('ACP machine permission policy', () => {
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ describe('ACP automation codec', () => {
|
||||
const cases: [TurnEndReason, string][] = [
|
||||
[{ kind: 'completed' }, 'end_turn'],
|
||||
[{ kind: 'max-tokens' }, 'max_tokens'],
|
||||
[{ kind: 'aborted' }, 'cancelled'],
|
||||
[{ kind: 'disposed' }, 'cancelled'],
|
||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
|
||||
[{ kind: 'interrupted' }, 'cancelled'],
|
||||
[{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'],
|
||||
[{ kind: 'error', error: new Error('boom') }, 'end_turn'],
|
||||
]
|
||||
for (const [reason, expected] of cases) expect(turnEndToStopReason(reason)).toBe(expected)
|
||||
})
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
if (subject !== agent || message.source.kind !== 'user' || inserted) return
|
||||
inserted = true
|
||||
const source = { kind: 'plugin', plugin: 'test' } as const
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'autonomous work' }],
|
||||
source,
|
||||
|
||||
@@ -114,7 +114,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
return seq
|
||||
}
|
||||
for (let turn = 0; turn < 60; turn++) {
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
const userSeq = push({
|
||||
type: 'user/message', surfaceOp: 'append',
|
||||
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`)),
|
||||
@@ -158,7 +158,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// stays presenter-less as the unknown fallback.
|
||||
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
|
||||
const callId = `fx-call-${turn}`
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
@@ -186,7 +186,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
|
||||
+ 'return { listing, demo }'
|
||||
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
|
||||
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
push({ type: 'turn/start', data: { turn } })
|
||||
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:run_code 样本。`)) })
|
||||
push({ type: 'step/start', data: { turn, step: 0 } })
|
||||
push({
|
||||
@@ -975,7 +975,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
const turn = nextTurn.get(id) ?? 0
|
||||
nextTurn.set(id, turn + 1)
|
||||
setRunning(id, true)
|
||||
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
append(id, { type: 'turn/start', data: { turn } })
|
||||
// Boundary flush parallel (the host's agent/step seam): an outstanding
|
||||
// /plan selection commits as plan/mode inside the opened turn.
|
||||
const plan = foldPlan(logOf(id))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
@@ -51,9 +52,8 @@ const QUEUE_PREVIEW_CHARS = 200
|
||||
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
|
||||
interface QueuedEntry {
|
||||
row: QueuedMessage
|
||||
steering: boolean
|
||||
/** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */
|
||||
sourceJson: string
|
||||
/** Stable message identity used when an admitted message retires the row. */
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
|
||||
@@ -370,8 +370,7 @@ export class Session implements SessionFace {
|
||||
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
|
||||
this.queued.push({
|
||||
row: { key, preview: queuePreviewOf(message.content) },
|
||||
steering: frame.steering,
|
||||
sourceJson: JSON.stringify(message.source),
|
||||
messageId: message.id,
|
||||
})
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
@@ -598,21 +597,16 @@ export class Session implements SessionFace {
|
||||
}
|
||||
}
|
||||
|
||||
/** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered
|
||||
* turn/start claims the oldest non-steering entry; a steering/message drains the oldest
|
||||
* steering entry with the same source (loop-authored steering matches nothing and drops none). */
|
||||
/** Retire the oldest queued occurrence of an admitted identified message. */
|
||||
private retireQueued(event: SessionEvent): void {
|
||||
if (this.queued.length === 0) return
|
||||
let index = -1
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind !== 'message') return
|
||||
index = this.queued.findIndex(entry => !entry.steering)
|
||||
} else if (event.type === 'steering/message') {
|
||||
const source = JSON.stringify(event.data.message.source)
|
||||
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
const id = event.type === 'user/message'
|
||||
? event.data.id
|
||||
: event.type === 'steering/message'
|
||||
? event.data.message.id
|
||||
: undefined
|
||||
if (id === undefined) return
|
||||
const index = this.queued.findIndex(entry => entry.messageId === id)
|
||||
if (index < 0) return
|
||||
this.queued.splice(index, 1)
|
||||
this.queueRev++
|
||||
|
||||
@@ -12,7 +12,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
|
||||
export const ev = {
|
||||
turnStart: (seq: number, turn: number): SessionEvent =>
|
||||
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
at(seq, { type: 'turn/start', data: { turn } }),
|
||||
user: (seq: number, body: string): SessionEvent =>
|
||||
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
|
||||
content: text(body), source: { kind: 'user' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Queue mirror semantics (web input-triggers queue cut 1): session/queued
|
||||
* intake, host-rule retirement (message turn/start claims oldest non-steering;
|
||||
* intake, host-rule retirement (identified user/message claims its non-steering row;
|
||||
* steering/message drains by source), leave-running sweep, reconnect reset,
|
||||
* pre-instantiation buffering, and snapshot reference stability.
|
||||
*/
|
||||
@@ -18,7 +18,11 @@ const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
const rid = (id: string): RpcId => id as RpcId
|
||||
|
||||
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
|
||||
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
|
||||
function queuedFrame(
|
||||
body: string,
|
||||
rpcId: string,
|
||||
steering = false,
|
||||
): Extract<MuxFrame, { type: 'session/queued' }> {
|
||||
return {
|
||||
type: 'session/queued',
|
||||
sessionId: SID,
|
||||
@@ -74,22 +78,30 @@ describe('queue intake', () => {
|
||||
})
|
||||
|
||||
describe('queue retirement (host queuedMirror rules)', () => {
|
||||
it('a message-triggered turn/start claims the oldest non-steering row', () => {
|
||||
it('an admitted user/message claims its identified non-steering row', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1'))
|
||||
const first = queuedFrame('先', 'p-1')
|
||||
session.handleMuxEnvelope(rid('e1'), first)
|
||||
session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2'))
|
||||
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) })
|
||||
session.handleMuxEnvelope(rid('e3'), {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: {
|
||||
...ev.user(0, '先'),
|
||||
data: first.message,
|
||||
},
|
||||
})
|
||||
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2'])
|
||||
})
|
||||
|
||||
it('an injection-triggered turn/start claims nothing', () => {
|
||||
it('a turn/start alone claims nothing', () => {
|
||||
const session = makeSession()
|
||||
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
|
||||
const injection = {
|
||||
...ev.turnStart(0, 0),
|
||||
data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
|
||||
} as never
|
||||
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
|
||||
session.handleMuxEnvelope(rid('e2'), {
|
||||
type: 'session/event',
|
||||
sessionId: SID,
|
||||
event: ev.turnStart(0, 0),
|
||||
})
|
||||
expect(session.getSnapshot().queue).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -155,8 +155,8 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('agent/settled', (agent) => {
|
||||
this.overflowRetries.delete(agent)
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
if (status === 'idle') this.overflowRetries.delete(agent)
|
||||
})
|
||||
|
||||
// A successful response starts a fresh overflow-recovery sequence even
|
||||
@@ -169,15 +169,11 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
ctx.on('agent/request-error', async (
|
||||
agent,
|
||||
_turn,
|
||||
_step,
|
||||
_error,
|
||||
failure,
|
||||
_priorFailures,
|
||||
_retryPolicy,
|
||||
context,
|
||||
signal,
|
||||
next,
|
||||
) => {
|
||||
const { failure } = context
|
||||
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
|
||||
this.overflowAgents.set(agent.session, agent)
|
||||
const target = routedTarget(agent.session)
|
||||
|
||||
@@ -104,7 +104,7 @@ function promptInput(text: string): SummarizationInput {
|
||||
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
const session = new Session(SessionId(`conversation-${turns}`))
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${text} user ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -133,7 +133,6 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
}
|
||||
session.append('turn/start', {
|
||||
turn: turns + 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
return session
|
||||
}
|
||||
@@ -142,7 +141,7 @@ function toolConversation(): Session {
|
||||
const session = new Session(SessionId('tools'))
|
||||
for (let turn = 1; turn <= 3; turn += 1) {
|
||||
const callId = CallId(`call-${turn}`)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `request ${turn} `.repeat(300) }],
|
||||
source: { kind: 'user' },
|
||||
@@ -182,7 +181,7 @@ function toolConversation(): Session {
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 4 })
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -190,7 +189,7 @@ function toolConversation(): Session {
|
||||
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
|
||||
const session = new Session(SessionId(`oversized-tool-${chars}`))
|
||||
const callId = CallId('oversized')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
if (withCompactablePrompt) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'older history '.repeat(200) }],
|
||||
@@ -227,7 +226,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -474,7 +473,7 @@ describe('pressure measurement and retention', () => {
|
||||
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = new Session(SessionId('headerless'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
|
||||
.resolves.toBeNull()
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
@@ -557,7 +556,7 @@ describe('pressure measurement and retention', () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = new Session(SessionId('single-tool-pair'))
|
||||
const callId = CallId('single-call')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
@@ -647,7 +646,7 @@ describe('pressure measurement and retention', () => {
|
||||
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const empty = new Session(SessionId('empty'))
|
||||
empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
empty.append('turn/start', { turn: 1 })
|
||||
empty.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
|
||||
reason: 'initial',
|
||||
@@ -723,7 +722,7 @@ describe('pressure measurement and retention', () => {
|
||||
const ctx = createContext()
|
||||
const session = new Session(SessionId('one-tool-pair'))
|
||||
const callId = CallId('only')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
@@ -1058,7 +1057,7 @@ describe('compaction region transaction', () => {
|
||||
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('model-less-region'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'history '.repeat(100) }],
|
||||
source: { kind: 'user' },
|
||||
@@ -1665,7 +1664,6 @@ describe('automatic listener and loader composition', () => {
|
||||
const session = new Session(SessionId('headerless-overflow'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
|
||||
|
||||
@@ -190,7 +190,6 @@ function overflowHistorySeed(): SessionEvent[] {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
|
||||
@@ -38,7 +38,6 @@ function appendToolStep(
|
||||
const callId = CallId(call)
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
@@ -165,7 +164,6 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
})
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
const result = service().pruneSession(session)
|
||||
@@ -214,7 +212,6 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
|
||||
session.append('turn/start', {
|
||||
turn: 4,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const prune = service()
|
||||
const first = prune.pruneSession(session)
|
||||
@@ -231,7 +228,6 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
service().pruneSession(session)
|
||||
const replay = new Session(session.id, [...session.events])
|
||||
@@ -250,7 +246,6 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/)
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(() => prune.pruneSession(session)).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ const summary = (overrides: Record<string, unknown> = {}) => ({
|
||||
})
|
||||
|
||||
function startTurn(session: ReturnType<Context['sessions']['create']>, turn = 1): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
}
|
||||
|
||||
describe('compaction invariants', () => {
|
||||
@@ -45,7 +45,7 @@ describe('compaction invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
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('compact/start', { turn: 1 })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(CompactInvariant)
|
||||
@@ -59,7 +59,7 @@ describe('compaction invariants', () => {
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
})
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 },
|
||||
|
||||
@@ -46,10 +46,10 @@ function reading(
|
||||
function preparing(turn: number, step: number): Session {
|
||||
const session = new Session(SessionId(`time-invariant-${turn}-${step}`))
|
||||
for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) {
|
||||
session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: priorTurn })
|
||||
session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -87,7 +87,7 @@ describe('time-context invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -103,7 +103,7 @@ describe('time-context invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -125,7 +125,7 @@ describe('time-context invariants', () => {
|
||||
it('rejects a reading after cancellation closes the turn', async () => {
|
||||
const ctx = await setup()
|
||||
const session = preparing(1, 2)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
|
||||
expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) })
|
||||
.toThrow(/inside an open turn/)
|
||||
})
|
||||
@@ -180,7 +180,7 @@ describe('time-context invariants', () => {
|
||||
expect(() => { ctx.emit('session/event', preparing(1, 1), user) }).not.toThrow()
|
||||
expect(() => {
|
||||
ctx.emit('session/event', preparing(1, 1), {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1 },
|
||||
})
|
||||
ctx.emit('tools/change')
|
||||
}).not.toThrow()
|
||||
|
||||
@@ -48,14 +48,13 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
send: () => {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function openMessageTurn(session: Session, turn: number): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `turn ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -159,7 +158,7 @@ describe('durable step context', () => {
|
||||
it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => {
|
||||
const { ctx } = await mount()
|
||||
const session = new Session(SessionId('unavailable'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
await fire(ctx, sessionAgent(session), 1, 1)
|
||||
|
||||
|
||||
@@ -183,7 +183,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
||||
inject(input) {
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
},
|
||||
send: () => {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
|
||||
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
|
||||
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Replay state is\n * retained only when the same adapter instance owns its historical provider\n * and the target provider. Final adapter selection remains fixed through\n * asynchronous exact-model resolution and dispatch. Adapter selection,\n * dispatch, and iteration failures become terminal `error` or `aborted`\n * finish chunks; middleware, nested-call, cleanup, and consumer failures\n * remain thrown.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1073,13 +1073,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */',
|
||||
summary: 'A declarative agent entry failed before it could publish a live agent.',
|
||||
},
|
||||
{
|
||||
name: 'agent/cancel-requested',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/cancel-requested\'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void',
|
||||
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - the explicit typed cancellation cause.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted.',
|
||||
},
|
||||
{
|
||||
name: 'agent/created',
|
||||
mode: 'emit',
|
||||
@@ -1102,32 +1095,25 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
summary: 'A step or turn errored.',
|
||||
},
|
||||
{
|
||||
name: 'agent/inbox/dequeue',
|
||||
name: 'agent/inbox/admitted',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/inbox/dequeue\'( this: Scoped<Agent>, agent: Agent, message: UserMessage, placement: InboxPlacement, ): void',
|
||||
jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message.\n * @param placement - the FIFO that claimed this occurrence; together with\n * `message.id`, it matches the earliest outstanding enqueue in that FIFO.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.',
|
||||
signature: '\'agent/inbox/admitted\'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void',
|
||||
jsDoc: '/**\n * The driver admitted one inbox item for model-visible history.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the admitted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'The driver admitted one inbox item for model-visible history.',
|
||||
},
|
||||
{
|
||||
name: 'agent/inbox/discard',
|
||||
name: 'agent/inbox/canceled',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[]): void',
|
||||
jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.',
|
||||
},
|
||||
{
|
||||
name: 'agent/inbox/enqueue',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: UserMessage, placement: InboxPlacement): void',
|
||||
jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param message - accepted content, source, and correlation identity.\n * @param placement - resolved queued or steering placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'An item entered the queued or steering inbox.',
|
||||
signature: '\'agent/inbox/canceled\'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void',
|
||||
jsDoc: '/**\n * One pending inbox item was dropped without entering model-visible\n * history. `cancel()` without `keepInbox`, including disposal, emits this\n * once for each dropped item before aborting active work.\n * @param agent - the agent whose inbox items were dropped.\n * @param message - the dropped message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'One pending inbox item was dropped without entering model-visible history.',
|
||||
},
|
||||
{
|
||||
name: 'agent/prompt-submit',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
|
||||
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message or opens a turn. Call `next()` for the unchanged default. The\n * signal controls only this admission attempt; listeners may cooperate with\n * it but must not retain it for a later attempt or turn.\n * @param agent - the agent whose turn claimed the message.\n * @param message - the frozen claimed message, including identity and source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message or opens a turn.',
|
||||
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
|
||||
jsDoc: '/**\n * Allow, rewrite, or block one claimed inbox batch before it becomes\n * model-visible or opens a turn. Call `next()` for the unchanged default. The\n * signal controls only this admission attempt; listeners may cooperate with\n * it but must not retain it for a later attempt or turn.\n * @param agent - the agent whose driver claimed the batch.\n * @param messages - the claimed messages.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Allow, rewrite, or block one claimed inbox batch before it becomes model-visible or opens a turn.',
|
||||
},
|
||||
{
|
||||
name: 'agent/request',
|
||||
@@ -1139,9 +1125,9 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'agent/request-error',
|
||||
mode: 'waterfall',
|
||||
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>',
|
||||
jsDoc: '/**\n * Handle a model-request failure after its failed step has closed but\n * before the failed turn closes. A listener returns `{ kind: \'retry\' }`\n * without calling `next()` when it owns the error, or calls `next()` to\n * delegate. The default `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another\n * retry turn in this consecutive sequence.\n * @param retryPolicy - immutable policy of the adapter registration that served\n * the failed request, or `undefined` if no final adapter served it.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Handle a model-request failure after its failed step has closed but before the failed turn closes.',
|
||||
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>',
|
||||
jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param context - request coordinates, provider, normalized failure, and serving policy.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
|
||||
summary: 'Handle one failed model-request attempt before the loop retries or closes its step.',
|
||||
},
|
||||
{
|
||||
name: 'agent/session-start',
|
||||
@@ -1150,18 +1136,11 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'The session lifecycle began, once before the first turn.',
|
||||
},
|
||||
{
|
||||
name: 'agent/settled',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/settled\'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void',
|
||||
jsDoc: '/**\n * One drain chain reached its terminal turn: that turn\'s `turn/end` is\n * already committed. Automatically recovered failed turns do not emit this\n * notification, and neither does a run that aborts or fails before its\n * `turn/start` commits — there is no durable turn to settle against.\n * `reason` says why; model-request recovery is exhausted when an error\n * reaches it.\n * @param agent - the agent whose turn closed.\n * @param turn - the terminal turn number.\n * @param reason - why the terminal turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'One drain chain reached its terminal turn: that turn\'s `turn/end` is already committed.',
|
||||
},
|
||||
{
|
||||
name: 'agent/status',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void',
|
||||
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). `send()` does not enter\n * `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'Agent status changed (`idle` ⇄ `running`).',
|
||||
},
|
||||
{
|
||||
@@ -1429,11 +1408,11 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
{
|
||||
name: 'Agent',
|
||||
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
|
||||
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentCancelCause',
|
||||
declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};',
|
||||
declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n} | {\n readonly kind: \'hook\';\n readonly reason: string;\n} | {\n readonly kind: \'disposed\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'AgentFactory',
|
||||
@@ -1549,7 +1528,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CancelOptions',
|
||||
declaration: 'export interface CancelOptions {\n keepInbox?: boolean;\n}',
|
||||
declaration: 'export interface CancelOptions {\n keepInbox?: boolean | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'CodeBindingErrorClass',
|
||||
@@ -1917,7 +1896,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'PreparedLlmCall',
|
||||
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly retryPolicy: ResolvedRetryPolicy;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PreparedReferencedMessage',
|
||||
@@ -2103,14 +2082,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ScopeKey',
|
||||
declaration: 'export type ScopeKey = object;',
|
||||
},
|
||||
{
|
||||
name: 'SendOptions',
|
||||
declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SendTarget',
|
||||
declaration: 'export type SendTarget = \'next-turn\' | \'next-step\';',
|
||||
},
|
||||
{
|
||||
name: 'Session',
|
||||
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
|
||||
@@ -2125,7 +2096,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMetadataFilter',
|
||||
@@ -2645,15 +2616,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'TurnEndReasonMap',
|
||||
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'TurnTrigger',
|
||||
declaration: 'export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];',
|
||||
},
|
||||
{
|
||||
name: 'TurnTriggerMap',
|
||||
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
|
||||
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: AgentCancelCause;\n };\n error: {\n kind: \'error\';\n error: unknown;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'UserInteractionProvider',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
|
||||
README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76
|
||||
README.zh.md: f9eb8aa3cdead427a88492e35c00eab80ba12f91
|
||||
README.md: 33e349f8945b45bf322171d4c02b9a940a68f2c2
|
||||
README.zh.md: 65a81ea82a02ea81bc3e0a8892fd23b281477df2
|
||||
|
||||
@@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
The concrete driver routes `followup()`/`steer()`/`inject()` through one private `send()` primitive. A follow-up joins the queued FIFO and wakes the driver; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
|
||||
### Loop lifecycle (`agent.ts`)
|
||||
|
||||
@@ -65,7 +65,7 @@ Every provider call that reaches a successful finish appends exactly one `assist
|
||||
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
|
||||
Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
|
||||
|
||||
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause.
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ interface Config {
|
||||
|
||||
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
|
||||
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
具体驱动器通过一个私有 `send()` 原语路由 `followup()`/`steer()`/`inject()`。后续消息加入排队 FIFO 并唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
|
||||
### 循环生命周期(`agent.ts`)
|
||||
|
||||
@@ -65,7 +65,7 @@ interface Config {
|
||||
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
|
||||
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
|
||||
插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
|
||||
|
||||
在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。
|
||||
|
||||
|
||||
@@ -7,47 +7,41 @@
|
||||
* @module dsh-agent-loop/agent
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type {
|
||||
Agent,
|
||||
CancelOptions,
|
||||
AgentInterruptReason,
|
||||
InboxPlacement,
|
||||
AgentCancelCause,
|
||||
AgentOptions,
|
||||
AgentStatus,
|
||||
SettleReason,
|
||||
PromptDecision,
|
||||
RequestError,
|
||||
CancelOptions,
|
||||
RequestErrorAction,
|
||||
SendOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
||||
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
BlockAssembler,
|
||||
LlmError,
|
||||
assertNever,
|
||||
createAssistantMessage,
|
||||
deepFreeze,
|
||||
errorChain,
|
||||
freezeMessage,
|
||||
isHarnessError,
|
||||
llmFailureOf,
|
||||
llmRetryPolicyOf,
|
||||
markAgentLoopRequest,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { AssistantMessage, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { Context } from 'cordis'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
|
||||
/** One completed step or a final-adapter failure eligible for recovery. */
|
||||
type StepOutcome =
|
||||
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
|
||||
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
|
||||
type Phase =
|
||||
| { kind: 'idle'; lastTurn: number }
|
||||
| { kind: 'collecting'; abort: AbortController; lastTurn: number }
|
||||
| { kind: 'running'; abort: AbortController; turn: number; step: number }
|
||||
|
||||
type Admission =
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'admitted'; claimed: UserMessage[]; messages: UserMessage[] }
|
||||
| { kind: 'blocked' }
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
|
||||
@@ -55,31 +49,18 @@ type StepOutcome =
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/** Prompts awaiting individual turns. */
|
||||
private queued: { message: UserMessage; wakeup: boolean }[] = []
|
||||
private queued: UserMessage[] = []
|
||||
/** Input taken into the session log at step boundaries. */
|
||||
private outbox: { message: UserMessage; steering: boolean }[] = []
|
||||
private outbox: UserMessage[] = []
|
||||
|
||||
/** Whether observers see a running interval; consecutive turns share it. */
|
||||
private busy = false
|
||||
/** Whether an idle waking send has deferred driver admission. */
|
||||
private wakeScheduled = false
|
||||
/** Whether next-step input belongs to the current admission or open turn. */
|
||||
acceptsNextStep = false
|
||||
/** Abort owner for the current admission or turn. */
|
||||
private abort: AbortController | undefined
|
||||
/** Resolves when the current admission and turn exit. */
|
||||
done: Promise<void> = Promise.resolve()
|
||||
private phase: Phase
|
||||
private driverDone: Promise<void> = Promise.resolve()
|
||||
|
||||
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
|
||||
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after the driver exits. */
|
||||
readonly scope: Scope
|
||||
/** The agent's scoped composition context ({@link Agent.ctx}). */
|
||||
readonly ctx: Context
|
||||
|
||||
/** Last turn number opened by this loop or present in its seeded log. */
|
||||
private lastTurn: number
|
||||
/** Whether the session log is owed a matching turn end event. */
|
||||
private turnOpen = false
|
||||
private stepOpen = false
|
||||
/** Whether this loop instance has appended its initial/resume request anchor. */
|
||||
private requestHeaderLogged = false
|
||||
|
||||
@@ -89,474 +70,282 @@ export class ReactLoopAgent implements Agent {
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
) {
|
||||
this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
|
||||
const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
|
||||
this.phase = { kind: 'idle', lastTurn }
|
||||
this.scope = createScope(loopCtx, this)
|
||||
this.ctx = this.scope.ctx.extend({ agent: this })
|
||||
}
|
||||
|
||||
/** Last activity state published to observers. */
|
||||
get status(): AgentStatus {
|
||||
return this.busy ? 'running' : 'idle'
|
||||
return this.phase.kind === 'idle' ? 'idle' : 'running'
|
||||
}
|
||||
|
||||
/** Commit a phase and publish its externally visible status transition. */
|
||||
private setPhase(next: Phase): void {
|
||||
const previousStatus = this.status
|
||||
this.phase = next
|
||||
const status = this.status
|
||||
if (status !== previousStatus) {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/status', status)
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept and route one unified send item. */
|
||||
send(
|
||||
message: UserMessage,
|
||||
options: SendOptions,
|
||||
): void {
|
||||
const { target, wakeup } = options
|
||||
if (target === 'next-step' && !wakeup) {
|
||||
if (this.acceptsNextStep) {
|
||||
this.outbox.push({ message, steering: false })
|
||||
return
|
||||
}
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
return
|
||||
private send(message: UserMessage, target: 'next-turn' | 'next-step', wakeup: boolean): void {
|
||||
this.session.append('agent/inbox/added', message)
|
||||
// Waking input cannot join an aborted admission or turn, so it starts the next turn.
|
||||
const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted
|
||||
const inbox = target === 'next-turn' || wakingAfterAbort ? this.queued : this.outbox
|
||||
inbox.push(message)
|
||||
if (wakeup) {
|
||||
this.scheduleKick()
|
||||
}
|
||||
|
||||
const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued'
|
||||
if (placement === 'steering') {
|
||||
this.outbox.push({ message, steering: true })
|
||||
} else {
|
||||
this.queued.push({ message, wakeup })
|
||||
}
|
||||
// Preserve the routing decision for every send in this synchronous caller
|
||||
// stack, while installing quiescence ownership before enqueue observers
|
||||
// can cancel or dispose.
|
||||
if (placement === 'queued' && wakeup) this.scheduleKick()
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement)
|
||||
}
|
||||
|
||||
/** Queue one ordinary prompt turn and wake the driver. */
|
||||
followup(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
target: 'next-turn',
|
||||
wakeup: true,
|
||||
})
|
||||
this.send(input, 'next-turn', true)
|
||||
}
|
||||
|
||||
/** Steer the open turn, falling back to a waking prompt while idle. */
|
||||
steer(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
target: 'next-step',
|
||||
wakeup: true,
|
||||
})
|
||||
this.send(input, 'next-step', true)
|
||||
}
|
||||
|
||||
/** Append model-facing context without waking the driver. */
|
||||
inject(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
target: 'next-step',
|
||||
wakeup: false,
|
||||
})
|
||||
this.send(input, 'next-step', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending work and abort the active turn; the first cause wins.
|
||||
* The cause is signal payload for observers and the durable turn/end
|
||||
* classification — it selects no machine behavior. Teardown is just
|
||||
* `cancel({kind:'disposed'})` + await {@link done} + {@link scope} dispose,
|
||||
* all owned by the factory.
|
||||
* `cancel({kind:'disposed'})` + driver join + {@link scope} dispose, all
|
||||
* owned by the factory.
|
||||
*/
|
||||
cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
|
||||
// Effective only when it aborts the active turn or actually discards
|
||||
// pending work: a keepInbox call with no active turn is a documented
|
||||
// no-op, so it must not emit cancel-requested for consumers to misread.
|
||||
const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0)
|
||||
if (this.abort !== undefined || discards) {
|
||||
// Observe-only: coordination consumers update their state before the
|
||||
// inboxes clear; listener failures are contained by the dispatcher.
|
||||
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
|
||||
}
|
||||
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void {
|
||||
if (!options.keepInbox) {
|
||||
const discarded = this.queued.map(item => item.message)
|
||||
for (const item of this.outbox) {
|
||||
if (item.steering) discarded.push(item.message)
|
||||
for (const message of [...this.outbox.splice(0), ...this.queued.splice(0)]) {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/canceled', message)
|
||||
}
|
||||
// Clear before abort observers run: replacement work belongs to the next turn.
|
||||
this.queued.length = 0
|
||||
this.outbox.length = 0
|
||||
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
|
||||
}
|
||||
const reason = Object.freeze({ kind: cause.kind })
|
||||
this.abort?.abort(reason)
|
||||
}
|
||||
|
||||
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
|
||||
async whenIdle(): Promise<void> {
|
||||
// `done` is replaced per activity, so re-reading it follows chained turns.
|
||||
// Every driver failure today is contained before it can reject `done`,
|
||||
// but the waiter must not gamble quiescence on that: a future escape
|
||||
// still counts as settled activity.
|
||||
/* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
|
||||
while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) {
|
||||
await this.done.catch(() => undefined)
|
||||
if (this.phase.kind !== 'idle') {
|
||||
this.phase.abort.abort(cause)
|
||||
}
|
||||
}
|
||||
|
||||
/** Defer idle admission while keeping {@link done} as its quiescence owner. */
|
||||
/** Reserve a driver before deferring idle admission. */
|
||||
private scheduleKick(): void {
|
||||
if (this.abort !== undefined || this.wakeScheduled) return
|
||||
this.wakeScheduled = true
|
||||
const pending = Promise.withResolvers<void>()
|
||||
const scheduled = pending.promise
|
||||
if (this.phase.kind !== 'idle') return
|
||||
const driver = Promise.withResolvers<void>()
|
||||
this.driverDone = driver.promise
|
||||
this.setPhase({ kind: 'collecting', abort: new AbortController(), lastTurn: this.phase.lastTurn })
|
||||
queueMicrotask(() => {
|
||||
this.wakeScheduled = false
|
||||
this.kick()
|
||||
const activity = this.done
|
||||
if (activity === scheduled) {
|
||||
pending.resolve()
|
||||
} else {
|
||||
void activity.then(
|
||||
() => { pending.resolve() },
|
||||
() => { pending.resolve() },
|
||||
)
|
||||
}
|
||||
this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject)
|
||||
})
|
||||
this.done = scheduled
|
||||
}
|
||||
|
||||
/** Resolve after the current driver and synchronous replacement chain exits. */
|
||||
async whenIdle(): Promise<void> {
|
||||
let driver: Promise<void>
|
||||
do {
|
||||
await (driver = this.driverDone)
|
||||
} while (driver !== this.driverDone)
|
||||
}
|
||||
|
||||
private async kick(): Promise<void> {
|
||||
try {
|
||||
while (await this.turn()) {}
|
||||
} catch (error: unknown) {
|
||||
if (this.phase.kind !== 'idle') {
|
||||
const turn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
|
||||
this.setPhase({ kind: 'idle', lastTurn: turn })
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, 0, error)
|
||||
}
|
||||
} finally {
|
||||
if (this.phase.kind === 'running') {
|
||||
this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Claim and admit the next queued prompt, then start its turn. */
|
||||
private kick(): void {
|
||||
if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return
|
||||
// The some() guard above proves the queue is non-empty; the non-null
|
||||
// assertion expresses that invariant.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const { message } = this.queued.shift()!
|
||||
const inheritedOutboxLength = this.outbox.length
|
||||
|
||||
const admission = new AbortController()
|
||||
this.abort = admission
|
||||
this.acceptsNextStep = true
|
||||
// Claimed admission is part of the running interval: it is cancellable
|
||||
// activity, so observers (and their cancel routing) must see it.
|
||||
if (!this.busy) {
|
||||
this.busy = true
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
|
||||
private async admit(onTurnBoundary: boolean): Promise<Admission> {
|
||||
if (this.phase.kind !== 'running') throw new Error()
|
||||
const signal = this.phase.abort.signal
|
||||
const claimed = this.outbox.slice()
|
||||
const outboxLength = this.outbox.length
|
||||
const queued = onTurnBoundary ? this.queued[0] : undefined
|
||||
if (queued !== undefined) claimed.push(queued)
|
||||
if (claimed.length === 0) return { kind: 'empty' }
|
||||
const decision = await agentEvents(this.loopCtx, this).waterfall(
|
||||
'agent/prompt-submit', claimed, signal,
|
||||
() => Promise.resolve({ kind: 'allow', messages: claimed }),
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (decision.kind === 'allow') {
|
||||
this.outbox.splice(0, outboxLength)
|
||||
if (queued !== undefined) this.queued.shift()
|
||||
return { kind: 'admitted', claimed, messages: decision.messages }
|
||||
} else {
|
||||
this.cancel({ kind: 'hook', reason: decision.reason }, { keepInbox: decision.keepInbox })
|
||||
return { kind: 'blocked' }
|
||||
}
|
||||
// The admission body runs synchronously up to the prompt-submit
|
||||
// waterfall's first await, so the waterfall snapshots its listeners
|
||||
// before a disposal initiated by the running-status emit above can
|
||||
// unregister a vetoing plugin.
|
||||
this.done = this.loopCtx.agents.withInitiator(this, async () => {
|
||||
const signal = admission.signal
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
// Admitted input stays on the stack until its turn/start commits: the
|
||||
// turn owns it only once the turn exists in the log.
|
||||
let admitted: UserMessage[] | undefined
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
const decision = await this.loopCtx.waterfall(
|
||||
agentCarrier(this), 'agent/prompt-submit', this, message, signal,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
|
||||
if (decision.kind === 'allow') {
|
||||
admitted = [decision.content === undefined
|
||||
? message
|
||||
: freezeMessage({ ...message, content: decision.content })]
|
||||
for (const context of decision.additionalContexts ?? []) {
|
||||
admitted.push(freezeMessage(context))
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!signal.aborted) {
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// cancel() aborts but never clears the slot, and kick()/run()
|
||||
// all refuse to install a new owner while one exists, so the admission
|
||||
// still owns the slot here and releasing it unconditionally is exact.
|
||||
this.abort = undefined
|
||||
if (admitted === undefined) {
|
||||
this.acceptsNextStep = false
|
||||
try {
|
||||
this.flushRejectedAdmissionContexts()
|
||||
} catch (error: unknown) {
|
||||
// No turn exists for agent/error coordinates. Preserve the
|
||||
// uncommitted suffix for a later boundary and report locally.
|
||||
this.loopCtx.logger.warn(
|
||||
`agent "${this.id}": committing rejected-admission context failed: ${errorChain(error)}`,
|
||||
)
|
||||
}
|
||||
// A synchronously aborted admission would otherwise publish idle
|
||||
// inside send()'s own synchronous extent, before any post-send
|
||||
// subscriber could observe the transition.
|
||||
await Promise.resolve()
|
||||
this.continueOrIdle()
|
||||
return
|
||||
}
|
||||
await this.run(trigger, admitted, inheritedOutboxLength)
|
||||
})
|
||||
// Published only after the abort owner and pending done are installed: a
|
||||
// dequeue listener that cancels or disposes must find live cancellation
|
||||
// and quiescence ownership, not the previous activity's settled state.
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message, 'queued')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one turn and any request-error retry. `admitted` input enters the log
|
||||
* only after `turn/start` commits; until then it has no owner state to unwind.
|
||||
*/
|
||||
private async run(
|
||||
trigger: TurnTrigger,
|
||||
admitted: UserMessage[] = [],
|
||||
inheritedOutboxLength = 0,
|
||||
priorFailures: readonly LlmFailure[] = Object.freeze([]),
|
||||
): Promise<void> {
|
||||
// Both entries hold the invariant: kick() clears the admission slot before
|
||||
// awaiting run(), and a retry is entered only after the prior run clears it.
|
||||
/* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */
|
||||
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
|
||||
const controller = new AbortController()
|
||||
this.abort = controller
|
||||
this.acceptsNextStep = true
|
||||
const signal = controller.signal
|
||||
const turn = this.lastTurn + 1
|
||||
let step = 0
|
||||
let opened = false
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
let settleReason: SettleReason = { kind: 'completed' }
|
||||
let requestFailureHistory = priorFailures
|
||||
let retryFailures: readonly LlmFailure[] | undefined
|
||||
const cancelRetry = (): void => { retryFailures = undefined }
|
||||
signal.addEventListener('abort', cancelRetry, { once: true })
|
||||
|
||||
private async turn(): Promise<boolean> {
|
||||
if (this.phase.kind === 'idle') throw new Error()
|
||||
const abort = this.phase.kind === 'collecting' ? this.phase.abort : new AbortController()
|
||||
const lastTurn = this.phase.kind === 'collecting' ? this.phase.lastTurn : this.phase.turn
|
||||
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
|
||||
this.setPhase(phase)
|
||||
if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0
|
||||
let admission: Admission
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
this.session.append('turn/start', { turn, trigger })
|
||||
// Committed: publish the turn to the machine's own bookkeeping and let
|
||||
// the admitted input enter the log it now belongs to.
|
||||
this.turnOpen = true
|
||||
opened = true
|
||||
this.lastTurn = turn
|
||||
// Context or steering retained by an earlier rejected admission happened
|
||||
// before this prompt and must occupy the same order in durable history.
|
||||
this.drainOutbox(turn, inheritedOutboxLength)
|
||||
for (const input of admitted) {
|
||||
this.session.append('user/message', input, { surfaceOp: 'append' })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
|
||||
this.drainOutbox(turn)
|
||||
|
||||
steps: while (true) {
|
||||
step += 1
|
||||
const outcome = await this.step(turn, step, signal)
|
||||
switch (outcome.kind) {
|
||||
case 'completed':
|
||||
requestFailureHistory = Object.freeze([])
|
||||
if (outcome.maxTokens) reason = { kind: 'max-tokens' }
|
||||
// A concluding tool result is terminal: steering already in the
|
||||
// log waits for the next turn's request instead of reopening this
|
||||
// one, and the agent/turn-stopping drain below is skipped for the same
|
||||
// reason.
|
||||
if (outcome.concluded) break steps
|
||||
if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
|
||||
break
|
||||
case 'request-failed': {
|
||||
// step() reports request failures only after step/start commits
|
||||
// and before its own step/end, so the step is always open here.
|
||||
this.stepOpen = false
|
||||
this.session.append('step/end', { turn, step })
|
||||
if (!signal.aborted) {
|
||||
try {
|
||||
const action = await this.loopCtx.waterfall(
|
||||
agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error,
|
||||
outcome.failure, requestFailureHistory, outcome.retryPolicy, signal,
|
||||
() => Promise.resolve<RequestErrorAction>(undefined),
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
|
||||
if (action?.kind === 'retry' && !signal.aborted) {
|
||||
retryFailures = Object.freeze([...requestFailureHistory, outcome.failure])
|
||||
}
|
||||
} catch (recoveryError: unknown) {
|
||||
this.loopCtx.logger.warn(
|
||||
`agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
|
||||
reason = settlement.reason
|
||||
settleReason = settlement.settleReason
|
||||
break steps
|
||||
admission = await this.admit(true)
|
||||
if (admission.kind !== 'admitted') return false
|
||||
abort.signal.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort while admission awaits
|
||||
if (abort.signal.aborted) return this.outbox.length > 0 || this.queued.length > 0
|
||||
throw error
|
||||
}
|
||||
const turn = ++phase.turn
|
||||
this.session.append('turn/start', { turn })
|
||||
let turnEnds: TurnEndReason | null = null
|
||||
try {
|
||||
while (true) {
|
||||
if (admission.kind === 'admitted') {
|
||||
for (const message of admission.claimed) {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/admitted', message)
|
||||
}
|
||||
for (const message of admission.messages) {
|
||||
this.session.append('user/message', message, { surfaceOp: 'append' })
|
||||
}
|
||||
/* v8 ignore next 2 -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(outcome)
|
||||
}
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
|
||||
signal.throwIfAborted()
|
||||
if (!this.drainOutbox(turn)) break
|
||||
}
|
||||
} catch (caught: unknown) {
|
||||
try {
|
||||
if (this.stepOpen) {
|
||||
this.stepOpen = false
|
||||
abort.signal.throwIfAborted()
|
||||
const step = ++phase.step
|
||||
this.session.append('step/start', { turn, step })
|
||||
try {
|
||||
turnEnds = await this.step()
|
||||
} finally {
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
} catch (closeError: unknown) {
|
||||
// Contained like the finally's turn close: a persistently rejecting
|
||||
// step boundary must not escape run(), or the post-finally tail would
|
||||
// never publish the terminal status and observers would see a
|
||||
// permanently running agent whose whenIdle() already resolved.
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError)
|
||||
}
|
||||
({ reason, settleReason } = this.settle(turn, step, caught, signal))
|
||||
} finally {
|
||||
// Every step-close happens before this point on both success and
|
||||
// failure paths (step(), the request-failed branch, the catch), so the
|
||||
// finally owes only the turn boundary.
|
||||
this.acceptsNextStep = false
|
||||
try {
|
||||
if (this.turnOpen) {
|
||||
// Re-entrant turn/end listeners must route new input to a later turn.
|
||||
this.turnOpen = false
|
||||
this.session.append('turn/end', { turn, reason })
|
||||
abort.signal.throwIfAborted()
|
||||
if (turnEnds && this.outbox.length === 0) {
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, abort.signal)
|
||||
abort.signal.throwIfAborted()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
retryFailures = undefined
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
|
||||
admission = await this.admit(false)
|
||||
if (admission.kind === 'blocked') {
|
||||
turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause }
|
||||
return false
|
||||
}
|
||||
abort.signal.throwIfAborted()
|
||||
if (admission.kind === 'empty' && turnEnds) break
|
||||
}
|
||||
// cancel() aborts but never clears the slot, and no second run can
|
||||
// install a controller while this one is still unwinding, so the slot
|
||||
// is still this run's controller here.
|
||||
this.abort = undefined
|
||||
signal.removeEventListener('abort', cancelRetry)
|
||||
}
|
||||
|
||||
if (opened) {
|
||||
try {
|
||||
await this.loopCtx.sessions.flush(this.session)
|
||||
} catch (error: unknown) {
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": session/flush failed at turn ${turn}: ${errorChain(error)}`)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (retryFailures !== undefined) {
|
||||
await this.run({ kind: 'retry' }, [], 0, retryFailures)
|
||||
} else {
|
||||
// agent/settled names only committed turns: a run aborted or rejected
|
||||
// before turn/start has no durable turn/end for consumers to settle
|
||||
// against, so it exits without the notification.
|
||||
if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason)
|
||||
this.continueOrIdle()
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancel may abort during any awaited turn operation
|
||||
if (abort.signal.aborted) turnEnds = { kind: 'aborted', reason: abort.signal.reason as AgentCancelCause }
|
||||
else turnEnds = { kind: 'error', error: errorChain(error) }
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the turn is always ended in this block
|
||||
this.session.append('turn/end', { turn, reason: turnEnds! })
|
||||
}
|
||||
return this.outbox.length > 0 || this.queued.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `agent/step` extension point, commit pending input, derive one
|
||||
* request, and execute its tool calls inside one durable step boundary.
|
||||
*/
|
||||
private async step(
|
||||
turn: number,
|
||||
step: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<StepOutcome> {
|
||||
const { session } = this
|
||||
|
||||
// The single between-steps extension point: listeners inject, steer, or
|
||||
// edit the log here; the request derives from the log after this settles.
|
||||
private async step(): Promise<TurnEndReason | null> {
|
||||
if (this.phase.kind !== 'running') throw new Error()
|
||||
const { turn, step, abort: { signal } } = this.phase
|
||||
signal.throwIfAborted()
|
||||
await this.loopCtx.serial(agentCarrier(this), 'agent/step', this, turn, step, signal)
|
||||
signal.throwIfAborted()
|
||||
|
||||
// Take the outbox whole — same-boundary steering and context leave in
|
||||
// this request together.
|
||||
this.drainOutbox(turn)
|
||||
|
||||
// Assemble the system prompt fresh each step (it may depend on log state).
|
||||
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
|
||||
signal.throwIfAborted()
|
||||
const system = renderPrompt(assembly)
|
||||
|
||||
// Snapshot the exact log prefix: the reconstruction boundary. Appends
|
||||
// after this synchronous snapshot join the next request.
|
||||
const boundaryMessages = session.deriveMessages()
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
this.stepOpen = true
|
||||
signal.throwIfAborted()
|
||||
|
||||
const { request, preparedCall } = await this.buildRequest(
|
||||
turn, step, assembly.tools, system, boundaryMessages, signal,
|
||||
)
|
||||
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
|
||||
try {
|
||||
let message: AssistantMessage
|
||||
while (true) {
|
||||
const boundaryMessages = this.session.deriveMessages()
|
||||
const { request, preparedCall } = await this.buildRequest(
|
||||
turn, step, assembly.tools, system, boundaryMessages, signal,
|
||||
)
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
|
||||
signal.throwIfAborted()
|
||||
for await (const chunk of stream) {
|
||||
signal.throwIfAborted()
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
const chunkEvent = this.session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const facts = llmFailureOf(stream, error)
|
||||
if (facts !== undefined && error instanceof Error) {
|
||||
return { kind: 'request-failed', error, failure: facts, retryPolicy: llmRetryPolicyOf(stream) }
|
||||
signal.throwIfAborted()
|
||||
const finish = assembler.finish
|
||||
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
||||
const action = await this.loopCtx.waterfall(
|
||||
agentCarrier(this), 'agent/request-error', this, {
|
||||
turn,
|
||||
step,
|
||||
provider: request.provider,
|
||||
failure: finish.failure,
|
||||
retryPolicy: preparedCall?.retryPolicy,
|
||||
}, signal,
|
||||
() => Promise.resolve<RequestErrorAction>(undefined),
|
||||
)
|
||||
signal.throwIfAborted()
|
||||
if (action?.kind !== 'retry') {
|
||||
return { kind: 'error', error: finish.failure }
|
||||
}
|
||||
} else {
|
||||
message = createAssistantMessage({
|
||||
content: assembler.blocks(),
|
||||
source: {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
||||
},
|
||||
})
|
||||
this.session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
message,
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
if (finish.kind === 'max-tokens') {
|
||||
return { kind: 'max-tokens' }
|
||||
}
|
||||
break
|
||||
}
|
||||
throw error
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
|
||||
// Failure finish chunks take the same path as thrown stream errors.
|
||||
const finish = assembler.finish
|
||||
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
||||
const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure)
|
||||
return { kind: 'request-failed', error, failure: finish.failure, retryPolicy: llmRetryPolicyOf(stream) }
|
||||
}
|
||||
|
||||
// Truncated (max-tokens) output cannot owe tool calls.
|
||||
const assembled = assembler.blocks()
|
||||
const content = finish.kind === 'max-tokens'
|
||||
? assembled.filter(block => block.type !== 'tool-call')
|
||||
: assembled
|
||||
const message: AssistantMessage = createAssistantMessage({
|
||||
content,
|
||||
source: {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
|
||||
},
|
||||
})
|
||||
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
message,
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
|
||||
const toolCalls = content.filter(block => block.type === 'tool-call')
|
||||
let concluded = false
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
let result: TurnEndReason | null
|
||||
if (toolCalls.length > 0) {
|
||||
({ concluded } = await executeToolCalls(
|
||||
const { concluded } = await executeToolCalls(
|
||||
this.loopCtx, turn, step, toolCalls, signal,
|
||||
context => this.outbox.push({ message: freezeMessage(context), steering: false }),
|
||||
))
|
||||
}
|
||||
|
||||
// Tool results stay adjacent to their calls; input accepted during the
|
||||
// request enters the log only after the complete result batch.
|
||||
const steered = this.drainOutbox(turn)
|
||||
session.append('step/end', { turn, step })
|
||||
this.stepOpen = false
|
||||
return {
|
||||
kind: 'completed',
|
||||
continueTurn: (toolCalls.length > 0 && !concluded) || steered,
|
||||
concluded,
|
||||
maxTokens: finish.kind === 'max-tokens',
|
||||
context => this.outbox.push(context),
|
||||
)
|
||||
result = concluded ? { kind: 'completed' } : null
|
||||
} else {
|
||||
result = { kind: 'completed' }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,11 +360,9 @@ export class ReactLoopAgent implements Agent {
|
||||
boundaryMessages: Message[],
|
||||
signal: AbortSignal,
|
||||
): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
|
||||
const { session } = this
|
||||
|
||||
// A loop instance starts from its declared route, restoring only an opaque
|
||||
// effort owned by that exact model. Later steps fold the config it logged.
|
||||
const persistedConfig = session.requestHeader()?.config
|
||||
const persistedConfig = this.session.requestHeader()?.config
|
||||
const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
|
||||
const reasoningEffort = persistedConfig?.provider === route.provider
|
||||
&& persistedConfig.model === route.model
|
||||
@@ -618,113 +405,23 @@ export class ReactLoopAgent implements Agent {
|
||||
...system ? { system } : {},
|
||||
...tools.length > 0 ? { tools } : {},
|
||||
})
|
||||
const baseline = session.requestHeader()
|
||||
const baseline = this.session.requestHeader()
|
||||
if (!this.requestHeaderLogged) {
|
||||
session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
|
||||
this.session.append('request/header', { header, reason: baseline === undefined ? 'initial' : 'resume' })
|
||||
this.requestHeaderLogged = true
|
||||
} else if (baseline === undefined || !headerEquals(baseline, header)) {
|
||||
session.append('request/header', { header, reason: 'change' })
|
||||
this.session.append('request/header', { header, reason: 'change' })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
|
||||
const request = markAgentLoopRequest(deepFreeze({
|
||||
...header.config,
|
||||
messages: boundaryMessages,
|
||||
...header.system !== undefined ? { system: header.system } : {},
|
||||
...header.tools !== undefined ? { tools: header.tools } : {},
|
||||
sessionId: session.id,
|
||||
sessionId: this.session.id,
|
||||
signal,
|
||||
}))
|
||||
return { request, ...preparedCall === undefined ? {} : { preparedCall } }
|
||||
}
|
||||
|
||||
/** Commit the outbox and report whether it contained steering. */
|
||||
private drainOutbox(turn: number, limit = this.outbox.length): boolean {
|
||||
let steered = false
|
||||
for (const item of this.outbox.splice(0, limit)) {
|
||||
if (item.steering) {
|
||||
steered = true
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message, 'steering')
|
||||
this.session.append(
|
||||
'steering/message',
|
||||
{ turn, message: item.message },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
} else {
|
||||
this.session.append('user/message', item.message, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
return steered
|
||||
}
|
||||
|
||||
/**
|
||||
* Give context-only input its ordinary idle placement when admission
|
||||
* produces no turn. Steering keeps the whole boundary staged so context
|
||||
* accepted beside it cannot split from the request it accompanies.
|
||||
*/
|
||||
private flushRejectedAdmissionContexts(): void {
|
||||
if (this.outbox.some(item => item.steering)) return
|
||||
const contexts = this.outbox.splice(0)
|
||||
for (let index = 0; index < contexts.length; index += 1) {
|
||||
const item = contexts[index]
|
||||
/* v8 ignore next 2 -- the steering precheck proves this batch is context-only */
|
||||
if (item === undefined || item.steering) throw new Error('rejected-admission context batch changed')
|
||||
try {
|
||||
this.session.append('user/message', item.message, { surfaceOp: 'append' })
|
||||
} catch (error: unknown) {
|
||||
this.outbox.unshift(...contexts.slice(index))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single settlement funnel: classify one turn failure (interruption
|
||||
* beats error) into the durable turn/end reason and live settlement report.
|
||||
*/
|
||||
private settle(
|
||||
turn: number,
|
||||
step: number,
|
||||
error: unknown,
|
||||
signal: AbortSignal,
|
||||
failure?: LlmFailure,
|
||||
): { reason: TurnEndReason; settleReason: SettleReason } {
|
||||
if (signal.aborted) {
|
||||
// Slot invariant, stated rather than re-validated: the turn controller
|
||||
// is machine-private and cancel() is its only aborter, always with one
|
||||
// frozen canonical cause as the reason.
|
||||
const interrupt = signal.reason as AgentInterruptReason
|
||||
return {
|
||||
reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' },
|
||||
settleReason: { kind: 'aborted' },
|
||||
}
|
||||
}
|
||||
if (failure !== undefined) {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
|
||||
// The durable record renders the full cause chain: turn/end is the one
|
||||
// durable trace of the failure, so a wrapper message alone would lose
|
||||
// the transport detail the log exists to keep.
|
||||
const rendered = errorChain(error)
|
||||
return {
|
||||
reason: { kind: 'error', step, failure: { ...failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } },
|
||||
settleReason: { kind: 'error', error, failure },
|
||||
}
|
||||
}
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
|
||||
return {
|
||||
reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} },
|
||||
settleReason: { kind: 'error', error },
|
||||
}
|
||||
}
|
||||
|
||||
/** Continue with a waking prompt, or publish the idle status. */
|
||||
private continueOrIdle(): void {
|
||||
if (this.queued.some(item => item.wakeup)) {
|
||||
this.kick()
|
||||
} else {
|
||||
// Every caller sits inside an admission or run whose install marked the
|
||||
// interval busy, so the flag is still set here.
|
||||
this.busy = false
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/status', 'idle')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,18 +384,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
if (machine === undefined) await machineReady.promise
|
||||
if (machine !== undefined) {
|
||||
machine.cancel({ kind: 'disposed' })
|
||||
// Drain to TRUE quiescence: cancel's own synchronous event chain
|
||||
// (running→idle) can legitimately re-enter through an automation
|
||||
// listener (goal-session's idle drive) and replace `done` with a
|
||||
// fresh admission before this await captures it. The replacement
|
||||
// work is cancelled and drained in turn until the slot stabilizes.
|
||||
let done = machine.done
|
||||
while (true) {
|
||||
await Promise.allSettled([done])
|
||||
if (machine.done === done) break
|
||||
done = machine.done
|
||||
machine.cancel({ kind: 'disposed' })
|
||||
}
|
||||
await machine.whenIdle()
|
||||
await machine.scope.dispose()
|
||||
}
|
||||
} finally {
|
||||
@@ -452,7 +441,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
loopCtx.agents.announce(agent)
|
||||
assertLive()
|
||||
// A synchronous announce/session-start listener may have started
|
||||
// teardown; the machine is already live (send() works from the
|
||||
// teardown; the machine is already live (delivery works from the
|
||||
// session-start seam), so only the liveness recheck is owed.
|
||||
emitAgentEvent(loopCtx, agent, 'agent/session-start', source)
|
||||
assertLive()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
* @module dsh-agent-loop/tests/cancel
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
@@ -55,33 +55,6 @@ function userTexts(agent: Agent): string[] {
|
||||
}
|
||||
|
||||
describe('Agent.cancel()', () => {
|
||||
it('notifies every observer before clearing work and contains listener failures', async () => {
|
||||
const adapter = new MockAdapter([textResponse('must remain unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
|
||||
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject !== agent) return
|
||||
seen.push(`first:${cause.kind}`)
|
||||
subject.followup(createUserMessage({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } }))
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject === agent) seen.push(`second:${cause.kind}`)
|
||||
})
|
||||
|
||||
send(agent, 'drop me')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
agent.cancel({ kind: 'parent' })
|
||||
|
||||
expect(seen).toEqual(['first:user', 'second:user'])
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
|
||||
})
|
||||
|
||||
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -103,62 +76,29 @@ describe('Agent.cancel()', () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const discards: unknown[] = []
|
||||
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
|
||||
const cancelRequests: unknown[] = []
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) })
|
||||
const canceled: unknown[] = []
|
||||
ctx.on('agent/inbox/canceled', (subject, message) => { if (subject === agent) canceled.push(message) })
|
||||
|
||||
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
// keepInbox cancel: no active turn, work preserved, no discard event. With
|
||||
// nothing to abort and nothing discarded, the call is a documented no-op,
|
||||
// so it emits no cancel-requested either.
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserved' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
// Abort the collecting activity while preserving its queued item.
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
expect(discards).toEqual([])
|
||||
expect(cancelRequests).toEqual([])
|
||||
expect(canceled).toEqual([])
|
||||
|
||||
// The preserved item still runs once the driver is woken by a later send.
|
||||
// The preserved item still runs once a later follow-up wakes the driver.
|
||||
send(agent, 'wake it')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
|
||||
})
|
||||
|
||||
it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
|
||||
// resolves (the agent is quiescent), leaving the item queued.
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
|
||||
// A later waking send drives the loop, and the quiet item rides along first.
|
||||
send(agent, 'wake')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
|
||||
})
|
||||
|
||||
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// send() queues synchronously (status still idle, loop microtask not yet
|
||||
// followup() queues synchronously (status still idle, loop microtask not yet
|
||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
||||
send(agent, 'drop me first')
|
||||
send(agent, 'drop me second')
|
||||
|
||||
@@ -506,24 +506,6 @@ describe('driver bookkeeping edges', () => {
|
||||
expect(agent.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('a whenIdle waiter survives a rejected driver promise', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' })
|
||||
// A throwing terminal-notification listener rejects the driver promise
|
||||
// (the run's containment covers only session appends); the waiter's
|
||||
// catch arm must treat that rejection as quiescence instead of
|
||||
// propagating it.
|
||||
ctx.on('agent/settled', (subject) => {
|
||||
if (subject === agent) throw new Error('settled listener exploded')
|
||||
})
|
||||
|
||||
send(agent, 'one')
|
||||
// Entered while the run owns the abort slot, the waiter awaits the
|
||||
// driver promise; its rejection must count as quiescence and resolve.
|
||||
await expect(agent.whenIdle()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
// The failure finish-chunk path returns request-failed AFTER step() has
|
||||
|
||||
@@ -25,7 +25,7 @@ function loopRequest<T extends object>(options: T): Readonly<T> {
|
||||
async function requestSetup() {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -74,7 +74,7 @@ describe('request-reconstruction invariant', () => {
|
||||
it('rejects loop requests with no boundary or header', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-bare'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
@@ -122,7 +122,7 @@ describe('request-reconstruction invariant', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
const session = ctx.sessions.create(SessionId('prepend-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
@@ -433,8 +433,6 @@ describe('agent loop', () => {
|
||||
// split the assistant tool call from the provider's tool-result message.
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
const ts0 = turnStarts[0]!
|
||||
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
|
||||
const result = agent.session.events.find(e => e.type === 'tool/result')!
|
||||
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(contexts).toHaveLength(2)
|
||||
@@ -1031,16 +1029,11 @@ describe('agent loop', () => {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
await idle
|
||||
|
||||
const triggers = agent.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.map(event => event.data.trigger)
|
||||
const turns = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const sources = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.source)
|
||||
expect(triggers).toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
|
||||
])
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(sources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'test' },
|
||||
|
||||
@@ -63,13 +63,9 @@ describe('agent/request-error', () => {
|
||||
retryPolicy: ResolvedRetryPolicy | undefined
|
||||
}[] = []
|
||||
const statuses: string[] = []
|
||||
const settledTurns: number[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
ctx.on('agent/settled', (subject, turn) => {
|
||||
if (subject === agent) settledTurns.push(turn)
|
||||
})
|
||||
ctx.on('agent/request-error', async (
|
||||
subject, turn, step, _error, failure, priorFailures, retryPolicy,
|
||||
) => {
|
||||
@@ -101,12 +97,7 @@ describe('agent/request-error', () => {
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
},
|
||||
])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger))
|
||||
.toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'retry' },
|
||||
{ kind: 'retry' },
|
||||
])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(seen.map(item => item.priorFailures.map(failure => failure.code)))
|
||||
.toEqual([[], ['RATE_LIMIT']])
|
||||
expect(seen.map(item => item.retryPolicy)).toEqual([
|
||||
@@ -114,7 +105,6 @@ describe('agent/request-error', () => {
|
||||
expect.objectContaining({ mode: 'normal' }),
|
||||
])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(settledTurns).toEqual([3])
|
||||
})
|
||||
|
||||
it('lets cancellation win over a retry action', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
// balanced completed turn is the smallest resumable log and avoids running
|
||||
// the model merely to construct this lifecycle fixture.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const session = ctx.sessions.create(sessionId, { seed })
|
||||
@@ -86,7 +86,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
createdAt: 1,
|
||||
})
|
||||
await first.ctx.sessionPersistence.append(sessionId, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
@@ -175,7 +175,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')]))
|
||||
const sessionId = SessionId('live-resume-race')
|
||||
const first = (await ctx.agents.create({ sessionId })).agent
|
||||
first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
first.session.append('turn/start', { turn: 1 })
|
||||
await ctx.sessions.flush(first.session)
|
||||
|
||||
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
|
||||
@@ -494,7 +494,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('agent scope lifecycle', () => {
|
||||
expect(agent.ctx.agent).toBe(agent)
|
||||
// The root accessor default: a plain context answers undefined, not a throw.
|
||||
expect(ctx.agent).toBeUndefined()
|
||||
await ctx.agents.get(SessionId('a1'))?.whenIdle()
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('records agents created through an agent context as non-root runtime children', async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
|
||||
README.md: 9ca79f28506b133a555bd7d1e984386c715fd9d6
|
||||
README.zh.md: 165f71f1b395bdf0c229e2c4b1a30e89347b6be1
|
||||
README.md: 2d373487b7ae17a68edfaa4c45d8479f869276a5
|
||||
README.zh.md: 9e3b043baa4832721c66d6d0752c8601ea9d817c
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives request coordinates, normalized failure facts, the serving registration's retry policy when available, and the signal. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity.
|
||||
|
||||
@@ -60,11 +60,10 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. The agent publishes or queues the complete value as-is without minting or replacing its identity. The message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry it so callers can correlate a queued item with its lifecycle; enqueue and dequeue also carry the resolved `queued | steering` placement so repeated message identities retire from the correct FIFO. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
|
||||
- `agent.followup(input)` — queue an ordinary follow-up turn and wake the driver. Each admitted item becomes the sole ordinary prompt in its turn; the [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.
|
||||
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
|
||||
- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement.
|
||||
- `agent.acceptsNextStep` — whether steering would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement.
|
||||
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
|
||||
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:它接收请求坐标、规范化失败事实、可用时的服务注册重试策略以及信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
|
||||
`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。
|
||||
|
||||
@@ -60,11 +60,10 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
每个插件面向的 handle:
|
||||
|
||||
- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。agent 会原样发布或排队完整值,不会生成或替换其标识。该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件会携带完整消息,调用方可据此把排队项与其生命周期关联;入队与出队事件还会携带解析出的 `queued | steering` 路由归类,使重复出现的消息标识能在正确的 FIFO 中完成结算。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
|
||||
- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
|
||||
- `agent.followup(input)`:排队一个普通后续轮次并唤醒驱动器。每个获准项都会成为其轮次中唯一的普通提示词;轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
|
||||
- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。
|
||||
- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。
|
||||
- `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。
|
||||
- `agent.acceptsNextStep`:steering 当前是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。
|
||||
- `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。
|
||||
- `agent.whenIdle()`:agent 从 `running` 结算后达到静默时解析(idle ⇒ 立即;disposed ⇒ 等待循环退出)。这是非拥有者的静默观测钩子:观察工作结算,但不 teardown agent。Teardown 独立存在;生命周期拥有者通过 `AgentHandle.dispose()` 停止并注销,并直接等待循环退出。
|
||||
- `agent.session`、`agent.status`、`agent.options`、`agent.id`
|
||||
|
||||
@@ -21,27 +21,6 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
}
|
||||
lastStatus.set(agent, status)
|
||||
}, { global: true })
|
||||
|
||||
// Inbox FIFO conservation: an item leaves the inbox (dequeue) or is dropped
|
||||
// (discard) only after it entered (enqueue), so the live outstanding count
|
||||
// per agent can never go negative. Injection bypasses the FIFOs entirely and
|
||||
// never appears on these events.
|
||||
const outstanding = new WeakMap<Agent, number>()
|
||||
ctx.on('agent/inbox/enqueue', (agent) => {
|
||||
outstanding.set(agent, (outstanding.get(agent) ?? 0) + 1)
|
||||
}, { global: true })
|
||||
ctx.on('agent/inbox/dequeue', (agent) => {
|
||||
const count = outstanding.get(agent) ?? 0
|
||||
if (count <= 0) fail('agent/inbox/dequeue without a matching prior enqueue')
|
||||
outstanding.set(agent, count - 1)
|
||||
}, { global: true })
|
||||
ctx.on('agent/inbox/discard', (agent, items) => {
|
||||
const count = outstanding.get(agent) ?? 0
|
||||
if (items.length > count) {
|
||||
fail(`agent/inbox/discard dropped ${items.length} items but only ${count} were outstanding`)
|
||||
}
|
||||
outstanding.set(agent, count - items.length)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -27,95 +28,55 @@ export interface AgentOptions {
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Which inbox queue a {@link Agent.send} item joins:
|
||||
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
|
||||
* - `next-step` — during prompt admission or an open turn, the item stages for
|
||||
* the next safe step boundary; otherwise it is promoted per its `wakeup`
|
||||
* flag.
|
||||
*/
|
||||
export type SendTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/** Resolved inbox placement reported when an accepted message is enqueued. */
|
||||
export type InboxPlacement = 'queued' | 'steering'
|
||||
|
||||
/**
|
||||
* Options for the unified {@link Agent.send} primitive over the
|
||||
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
|
||||
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
|
||||
* {@link Agent.inject} (`next-step`/no-wakeup).
|
||||
*
|
||||
* The object is complete so routing policy is explicit.
|
||||
*/
|
||||
export interface SendOptions {
|
||||
/** Queue the item joins. */
|
||||
target: SendTarget
|
||||
/**
|
||||
* Whether this item makes the model run: wake a parked driver (`next-turn`)
|
||||
* or force a continuation step (`next-step` while running). A `false`
|
||||
* `next-turn` item queues without waking; a `false`
|
||||
* `next-step` item attaches durable context without forcing another step
|
||||
* (the injection preset).
|
||||
*/
|
||||
wakeup: boolean
|
||||
}
|
||||
|
||||
/** Options for {@link Agent.cancel}. */
|
||||
export interface CancelOptions {
|
||||
/**
|
||||
* Preserve queued and steering inbox items instead of discarding them. The
|
||||
* active turn is still aborted, but un-started and pending work survives for a
|
||||
* later turn and no `agent/inbox/discard` fires.
|
||||
* later turn and no `agent/inbox/canceled` fires.
|
||||
*/
|
||||
keepInbox?: boolean
|
||||
keepInbox?: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (the driver is draining
|
||||
* work and may be closing or checkpointing a turn). Disposal removes the
|
||||
* agent from its registry; it is not a third observable status.
|
||||
* `idle` means no driver is scheduled or active; `running` begins when a
|
||||
* cancellable admission is scheduled and lasts while the driver drains,
|
||||
* closes, or checkpoints turns. Disposal removes the agent from its registry;
|
||||
* it is not a third observable status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running'
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt, while
|
||||
* `additionalContexts` appends model-facing context before the turn starts.
|
||||
* An `allow` returned by a listener is authoritative: a listener wrapping
|
||||
* `next()` preserves both fields unless it intentionally replaces them.
|
||||
* Prompt interception result. An allowed batch replaces the submitted
|
||||
* messages. A listener wrapping `next()` preserves the returned batch unless
|
||||
* it intentionally replaces it.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessage[] }
|
||||
| { kind: 'block'; reason: string }
|
||||
| { kind: 'allow'; messages: UserMessage[] }
|
||||
| { kind: 'block'; reason: string; keepInbox?: boolean }
|
||||
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
export type RequestError = Error & { code?: string }
|
||||
/** One failed model-request attempt presented to recovery listeners. */
|
||||
export interface RequestFailureContext {
|
||||
/** Turn containing the failed request. */
|
||||
readonly turn: number
|
||||
/** Step containing the failed request attempt. */
|
||||
readonly step: number
|
||||
/** Provider selected for the failed request. */
|
||||
readonly provider: string
|
||||
/** Serializable facts normalized at the final adapter boundary. */
|
||||
readonly failure: LlmFailure
|
||||
/** Policy of the adapter registration that served the failed request. */
|
||||
readonly retryPolicy: ResolvedRetryPolicy | undefined
|
||||
}
|
||||
|
||||
/** Action returned by a listener that owns model-request recovery. */
|
||||
export type RequestErrorAction = { kind: 'retry' } | undefined
|
||||
|
||||
/**
|
||||
* Why a turn ended, reported live on `agent/settled` right after the turn's
|
||||
* durable `turn/end`. `error` carries the thrown value verbatim for observers;
|
||||
* model-request recovery runs earlier through `agent/request-error`.
|
||||
*/
|
||||
export type SettleReason =
|
||||
| { kind: 'completed' }
|
||||
| { kind: 'aborted' }
|
||||
| { kind: 'error'; error: unknown; failure?: LlmFailure }
|
||||
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/** Stable runtime cause accepted by {@link Agent.cancel}. */
|
||||
export type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
| { readonly kind: 'parent' }
|
||||
|
||||
/** Runtime reason carried by the signal that controls one live turn. */
|
||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||
|
||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||
/** Public live-agent handle. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
@@ -125,77 +86,46 @@ export interface Agent {
|
||||
readonly session: Session
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/**
|
||||
* Whether a `next-step` send currently stages for prompt admission or the
|
||||
* open turn. Unlike {@link status}, this excludes admission exit and turn
|
||||
* settlement, when a waking `next-step` send becomes a queued follow-up.
|
||||
*/
|
||||
readonly acceptsNextStep: boolean
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
|
||||
* It routes the caller's typed content and source as follows:
|
||||
*
|
||||
* - `next-turn` queues an item that becomes the sole ordinary message of its
|
||||
* own FIFO-ordered turn; `wakeup:true` wakes a
|
||||
* parked driver, while `wakeup:false` queues without waking.
|
||||
* - `next-step` with `wakeup:true` stages steering during prompt admission
|
||||
* or an open turn; outside that window it falls back to a woken
|
||||
* `next-turn`.
|
||||
* - `next-step` with `wakeup:false` injects durable model-facing context
|
||||
* without running the model: admission or an open turn stages it for the
|
||||
* next safe log position, while an injection outside that window appends
|
||||
* immediately without opening a turn. If admission closes without a turn,
|
||||
* a context-only boundary appends immediately; context staged beside
|
||||
* steering remains pending with it.
|
||||
* The agent publishes or queues the identified frozen message as-is.
|
||||
* @param message - identified model-facing content and its producer provenance.
|
||||
* @param options - target queue and wakeup decision.
|
||||
*/
|
||||
send(message: UserMessage, options: SendOptions): void
|
||||
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn. An effective call first emits `agent/cancel-requested` with the
|
||||
* resolved typed cause. The first cause wins for the active turn, and
|
||||
* `whenIdle()` resolves after cancellation reaches quiescence. Idle
|
||||
* cancellation is a no-op and does not arm later work.
|
||||
* turn. The first cause wins for the active turn. Idle cancellation is a
|
||||
* no-op and does not arm later work.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
/**
|
||||
* Resolve after the current whole-agent activity reaches quiescence. This
|
||||
* follows replacement work scheduled before the observed driver retires,
|
||||
* but does not identify the settlement of any particular message.
|
||||
* @returns fulfillment after no scheduled or active driver remains.
|
||||
*/
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
/**
|
||||
* Queue an ordinary follow-up turn and wake the driver — the
|
||||
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
|
||||
* ordinary message of its own turn.
|
||||
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
|
||||
* sole ordinary message of its own turn.
|
||||
* @param message - identified prompt content and its producer provenance.
|
||||
*/
|
||||
followup(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Submit steering during prompt admission or an open turn — the
|
||||
* `next-step`/wakeup preset of {@link send}. It stages for the next steering
|
||||
* checkpoint before a request or stop decision. If the activity fails before
|
||||
* that boundary, the remainder stays staged without waking the agent; retry
|
||||
* or a later prompt takes it. Outside that window steering falls back to a
|
||||
* woken follow-up turn, while cancellation or disposal may discard pending
|
||||
* steering.
|
||||
* Submit steering for the nearest step. An idle driver schedules a turn;
|
||||
* collecting and running drivers consume it at their next step boundary.
|
||||
* Cancellation or disposal may discard pending steering.
|
||||
* @param message - identified steering content and its producer provenance.
|
||||
*/
|
||||
steer(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model — the
|
||||
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
|
||||
* stages it at the next safe log position; outside that window it appends
|
||||
* immediately without opening a turn. If admission closes without a turn,
|
||||
* a context-only boundary appends immediately; context staged beside
|
||||
* Append model-facing context without running the model. Admission or an
|
||||
* open turn stages it at the next safe log position; outside that window it
|
||||
* appends immediately without opening a turn. If admission closes without a
|
||||
* turn, a context-only boundary appends immediately; context staged beside
|
||||
* steering remains pending with it.
|
||||
* @param message - identified injected context and its producer provenance.
|
||||
*/
|
||||
@@ -226,8 +156,9 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`). `send()` does not enter
|
||||
* `running` synchronously; drive lifecycle from this event.
|
||||
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters
|
||||
* `running` synchronously after reserving cancellation; `idle` means no
|
||||
* driver remains scheduled or active.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -235,56 +166,23 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* An item entered the queued or steering inbox. `placement` is the
|
||||
* acceptance-time routing result; listeners must not reconstruct it from
|
||||
* later agent or session state.
|
||||
* @param agent - the owning agent.
|
||||
* @param message - accepted content, source, and correlation identity.
|
||||
* @param placement - resolved queued or steering placement.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: UserMessage, placement: InboxPlacement): void
|
||||
/**
|
||||
* The driver claimed one item out of the inbox: a queued item at a turn
|
||||
* boundary, or steering drained between steps. Fires after the item leaves
|
||||
* its FIFO and before it becomes a durable message.
|
||||
* The driver admitted one inbox item for model-visible history.
|
||||
* @param agent - the agent whose inbox item was claimed.
|
||||
* @param message - the claimed message.
|
||||
* @param placement - the FIFO that claimed this occurrence; together with
|
||||
* `message.id`, it matches the earliest outstanding enqueue in that FIFO.
|
||||
* @param message - the admitted message.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/dequeue'(
|
||||
this: Scoped<Agent>,
|
||||
agent: Agent,
|
||||
message: UserMessage,
|
||||
placement: InboxPlacement,
|
||||
): void
|
||||
'agent/inbox/admitted'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void
|
||||
/**
|
||||
* Pending inbox items were dropped without delivering them, so every
|
||||
* enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR
|
||||
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
|
||||
* emits this after `agent/cancel-requested` when applicable and before
|
||||
* aborting the active work. Fires once per drop with every dropped item.
|
||||
* One pending inbox item was dropped without entering model-visible
|
||||
* history. `cancel()` without `keepInbox`, including disposal, emits this
|
||||
* once for each dropped item before aborting active work.
|
||||
* @param agent - the agent whose inbox items were dropped.
|
||||
* @param messages - the discarded messages in FIFO order (queued then steering); never empty.
|
||||
* @param message - the dropped message.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[]): void
|
||||
/**
|
||||
* Effective broad cancellation was requested, before queued/outbox work
|
||||
* is cleared or the active turn is aborted. This observe-only notification
|
||||
* cannot veto cancellation; listener failures are contained.
|
||||
* @param agent - the agent whose current work is being cancelled.
|
||||
* @param cause - the explicit typed cancellation cause.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/cancel-requested'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void
|
||||
|
||||
'agent/inbox/canceled'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void
|
||||
// ---- session lifecycle (emit) ----
|
||||
/**
|
||||
* The session lifecycle began, once before the first turn. Use
|
||||
@@ -300,17 +198,17 @@ declare module 'cordis' {
|
||||
|
||||
// ---- the machine's extension seams ----
|
||||
/**
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message or opens a turn. Call `next()` for the unchanged default. The
|
||||
* Allow, rewrite, or block one claimed inbox batch before it becomes
|
||||
* model-visible or opens a turn. Call `next()` for the unchanged default. The
|
||||
* signal controls only this admission attempt; listeners may cooperate with
|
||||
* it but must not retain it for a later attempt or turn.
|
||||
* @param agent - the agent whose turn claimed the message.
|
||||
* @param message - the frozen claimed message, including identity and source.
|
||||
* @param agent - the agent whose driver claimed the batch.
|
||||
* @param messages - the claimed messages.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, message: UserMessage, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Awaited serial checkpoint before EVERY request of a turn is built (the
|
||||
* first as well as each post-tools continuation). The single "between
|
||||
@@ -338,24 +236,17 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Handle a model-request failure after its failed step has closed but
|
||||
* before the failed turn closes. A listener returns `{ kind: 'retry' }`
|
||||
* without calling `next()` when it owns the error, or calls `next()` to
|
||||
* delegate. The default `undefined` leaves the failure terminal.
|
||||
* Handle one failed model-request attempt before the loop retries or closes
|
||||
* its step. A listener returns `{ kind: 'retry' }` without calling `next()`
|
||||
* when it owns recovery, or calls `next()` to delegate. The default
|
||||
* `undefined` leaves the failure terminal.
|
||||
* @param agent - the agent whose request failed.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the failed step number.
|
||||
* @param error - the original model-request failure.
|
||||
* @param failure - serializable facts normalized at the final adapter boundary.
|
||||
* @param priorFailures - immutable failures that already authorized another
|
||||
* retry turn in this consecutive sequence.
|
||||
* @param retryPolicy - immutable policy of the adapter registration that served
|
||||
* the failed request, or `undefined` if no final adapter served it.
|
||||
* @param context - request coordinates, provider, normalized failure, and serving policy.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
|
||||
/**
|
||||
* The turn is about to close: the model owes no response (no live tool
|
||||
* calls, no fresh steering). Awaited before the boundary commits — a
|
||||
@@ -371,21 +262,6 @@ declare module 'cordis' {
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* One drain chain reached its terminal turn: that turn's `turn/end` is
|
||||
* already committed. Automatically recovered failed turns do not emit this
|
||||
* notification, and neither does a run that aborts or fails before its
|
||||
* `turn/start` commits — there is no durable turn to settle against.
|
||||
* `reason` says why; model-request recovery is exhausted when an error
|
||||
* reaches it.
|
||||
* @param agent - the agent whose turn closed.
|
||||
* @param turn - the terminal turn number.
|
||||
* @param reason - why the terminal turn ended, with live error facts when it failed.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/settled'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
* A step or turn errored. The machine reports a failure here (plus the
|
||||
@@ -400,3 +276,10 @@ declare module 'cordis' {
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** One message was accepted into the agent inbox. */
|
||||
'agent/inbox/added': UserMessage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, {
|
||||
agentEvents,
|
||||
@@ -21,14 +20,11 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
options: {},
|
||||
session: new Session(id),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
}
|
||||
return Object.assign(agent, overrides)
|
||||
}
|
||||
@@ -188,7 +184,6 @@ describe('agentEvents()', () => {
|
||||
describe('explicit cancellation contract', () => {
|
||||
it('exposes the closed typed cancellation cause at the Agent seam', () => {
|
||||
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
|
||||
expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and optional invariant companion as independent bundles. */
|
||||
/** Build the package root and companions as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
|
||||
@@ -8,18 +8,15 @@
|
||||
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
|
||||
|
||||
const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
|
||||
'agent/cancel-requested': args => args[0],
|
||||
'agent/created': args => args[0],
|
||||
'agent/disposed': args => args[0],
|
||||
'agent/error': args => args[0],
|
||||
'agent/inbox/dequeue': args => args[0],
|
||||
'agent/inbox/discard': args => args[0],
|
||||
'agent/inbox/enqueue': args => args[0],
|
||||
'agent/inbox/admitted': args => args[0],
|
||||
'agent/inbox/canceled': args => args[0],
|
||||
'agent/prompt-submit': args => args[0],
|
||||
'agent/request': args => args[0],
|
||||
'agent/request-error': args => args[0],
|
||||
'agent/session-start': args => args[0],
|
||||
'agent/settled': args => args[0],
|
||||
'agent/status': args => args[0],
|
||||
'agent/step': args => args[0],
|
||||
'agent/turn-stopping': args => args[0],
|
||||
|
||||
@@ -51,7 +51,6 @@ describe('scoped-dispatch invariants', () => {
|
||||
'agent/inbox/enqueue': [agent, message, 'queued'],
|
||||
'agent/inbox/dequeue': [agent, message, 'queued'],
|
||||
'agent/inbox/discard': [agent, []],
|
||||
'agent/cancel-requested': [agent, { kind: 'user' }],
|
||||
'agent/session-start': [agent, 'startup'],
|
||||
'agent/step': [agent, 1, 1, signal],
|
||||
'agent/prompt-submit': [agent, message, signal, () => Promise.resolve({ kind: 'allow' })],
|
||||
@@ -68,7 +67,6 @@ describe('scoped-dispatch invariants', () => {
|
||||
() => Promise.resolve(undefined),
|
||||
],
|
||||
'agent/turn-stopping': [agent, 1, signal],
|
||||
'agent/settled': [agent, 1, { kind: 'completed' }],
|
||||
'agent/error': [agent, 1, 0, new Error('x')],
|
||||
} satisfies { [K in AgentEventName]: EventArgs<K> }
|
||||
const rows: Array<[string, unknown[]]> = [
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/session/README.md
|
||||
README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412
|
||||
README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989
|
||||
README.md: af93791dfc17f66b79b376ba32ec657761ec63bc
|
||||
README.zh.md: ed9bba76d307a764a20c7cc4a3d2716c55a1acc0
|
||||
|
||||
@@ -14,7 +14,6 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
|
||||
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
|
||||
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -30,27 +30,6 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
/**
|
||||
* Find the latest closed message-triggered turn, ignoring other triggers and
|
||||
* between-turn events.
|
||||
* @param events - session events, or an owned suffix, to inspect.
|
||||
* @returns the latest matching turn end, or `undefined`.
|
||||
*/
|
||||
export function findLastMessageTurnEnd(
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEvent<'turn/end'> | undefined {
|
||||
const messageTurns = new Set<number>()
|
||||
let latest: SessionEvent<'turn/end'> | undefined
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') {
|
||||
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessions: SessionStore
|
||||
|
||||
@@ -3,8 +3,6 @@ import type {
|
||||
AssistantMessage,
|
||||
CallId,
|
||||
LlmCallConfig,
|
||||
LlmFailure,
|
||||
MessageSource,
|
||||
StreamChunk,
|
||||
TokenUsage,
|
||||
ToolResultMessage,
|
||||
@@ -87,24 +85,12 @@ export interface CreateSessionOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What started a turn.
|
||||
* Merge-extensible sum type (same pattern as MessageSourceMap).
|
||||
*/
|
||||
export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
/** Recovery turn reopened over the repaired current session log. */
|
||||
retry: { kind: 'retry' }
|
||||
/**
|
||||
* An out-of-band producer explicitly enclosed injected context in a one-shot
|
||||
* turn. `Agent.inject()` appends idle context directly and does not use this
|
||||
* trigger; the source mirrors the producer of the enclosed `user/message`.
|
||||
*/
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
|
||||
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
/** Why an active agent driver was cancelled. */
|
||||
export type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
| { readonly kind: 'parent' }
|
||||
| { readonly kind: 'hook'; readonly reason: string }
|
||||
| { readonly kind: 'disposed' }
|
||||
|
||||
/**
|
||||
* Why a turn ended. Merge-extensible sum type.
|
||||
@@ -112,20 +98,11 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
export interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
/** A cancellation request interrupted the live turn. */
|
||||
aborted: { kind: 'aborted' }
|
||||
aborted: { kind: 'aborted'; reason: AgentCancelCause }
|
||||
/**
|
||||
* The turn failed: a step threw or the model reported a failure. `step` is the
|
||||
* step number the failure occurred on (the operational error's location — the
|
||||
* single durable record of an in-turn failure; live diagnostics also fire via
|
||||
* `agent/error`). Final model-request failures retain their normalized facts
|
||||
* as one `failure`; other thrown values retain their rendered message and a
|
||||
* real `HarnessError` code when present.
|
||||
* The turn failed.
|
||||
*/
|
||||
error: { kind: 'error'; step: number } & (
|
||||
| { failure: LlmFailure; message?: never; code?: never }
|
||||
| { message: string; code?: string; failure?: never }
|
||||
)
|
||||
disposed: { kind: 'disposed' }
|
||||
error: { kind: 'error'; error: unknown }
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
@@ -185,9 +162,11 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started the model loop.
|
||||
* Opens turn `turn`. Every turn begins when the loop admits queued input;
|
||||
* the following identified `user/message` event or batch records the
|
||||
* admitted input.
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
'turn/start': { turn: number }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* awaits `session/flush` after an ordinary turn ends before claiming the next
|
||||
|
||||
@@ -22,7 +22,7 @@ function scratch(session: Session): unknown {
|
||||
describe('derived-message cache', () => {
|
||||
it('stays deep-equal to a from-scratch replay derivation as the log grows', () => {
|
||||
const session = new Session(SessionId('cache-grow'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
userText(session, 'one')
|
||||
expect(session.deriveMessages()).toEqual(scratch(session))
|
||||
userText(session, 'two')
|
||||
@@ -55,7 +55,7 @@ describe('derived-message cache', () => {
|
||||
|
||||
it('rebuilds on a surface replace and still matches scratch', () => {
|
||||
const session = new Session(SessionId('cache-replace'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
userText(session, 'one')
|
||||
userText(session, 'two')
|
||||
const beforeReplace = session.deriveMessages()
|
||||
@@ -73,7 +73,7 @@ describe('derived-message cache', () => {
|
||||
|
||||
it('returns a fresh array per call: later appends never grow a held snapshot', () => {
|
||||
const session = new Session(SessionId('cache-snapshot'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
userText(session, 'one')
|
||||
const first = session.deriveMessages()
|
||||
userText(session, 'two')
|
||||
@@ -90,7 +90,7 @@ describe('derived-message cache', () => {
|
||||
describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
it('projects one appended event exactly as the full derivation projects its node', () => {
|
||||
const session = new Session(SessionId('per-event'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const event = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -100,7 +100,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
|
||||
it('reuses the logged event\'s already frozen content', () => {
|
||||
const session = new Session(SessionId('per-event-clone'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const event = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -114,7 +114,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
|
||||
it('projects null for events that produce no message (boundaries, empty assistant)', () => {
|
||||
const session = new Session(SessionId('per-event-null'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const boundary = session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(session.deriveEventMessage(boundary)).toBeNull()
|
||||
const empty = session.append('assistant/message', {
|
||||
|
||||
@@ -22,7 +22,7 @@ function appendClosedTurn(
|
||||
text = `hello ${turn}`,
|
||||
reason: TurnEndReason = { kind: 'completed' },
|
||||
): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
@@ -31,7 +31,7 @@ function appendClosedTurn(
|
||||
}
|
||||
|
||||
function appendOpenTurn(session: Session, turn: number): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `open ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -205,23 +205,23 @@ describe('SessionStore.fork', () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const cases: [string, (session: Session) => number][] = [
|
||||
['turn/start', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
['step/start', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
['user/message', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
return lastSeq(session)
|
||||
}],
|
||||
['assistant/message', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
@@ -238,7 +238,7 @@ describe('SessionStore.fork', () => {
|
||||
}],
|
||||
['tool/call', (session) => {
|
||||
const callId = CallId('call-open')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
@@ -279,7 +279,7 @@ describe('SessionStore.fork', () => {
|
||||
it('rejects a duplicate child session id before validating the boundary', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('open-parent'))
|
||||
source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
source.append('turn/start', { turn: 1 })
|
||||
ctx.sessions.create(SessionId('child'))
|
||||
|
||||
expect(() => sessions.fork(source, undefined, SessionId('child')))
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('session-log invariants', () => {
|
||||
await scopedCtx.plugin(SessionInvariant)
|
||||
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
@@ -35,7 +35,7 @@ describe('session-log invariants', () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -78,11 +78,10 @@ describe('session-log invariants', () => {
|
||||
})
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('later dispatch veto')
|
||||
expect(session.events).toEqual([])
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
@@ -94,7 +93,7 @@ describe('session-log invariants', () => {
|
||||
const session = ctx.sessions.create(SessionId('postcommit-peer'))
|
||||
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
expect(warnings).toHaveLength(2)
|
||||
@@ -107,7 +106,7 @@ describe('session-log invariants', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
} as never)
|
||||
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
|
||||
type: 'turn/end',
|
||||
@@ -120,16 +119,16 @@ describe('session-log invariants', () => {
|
||||
it('enforces turn numbering and core execution enclosure', async () => {
|
||||
const first = await setup()
|
||||
const open = first.ctx.sessions.create()
|
||||
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
open.append('turn/start', { turn: 1 })
|
||||
expect(() => open.append('turn/start', { turn: 2 }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
|
||||
.toThrow(/does not match open turn 1/)
|
||||
|
||||
const second = (await setup()).ctx.sessions.create()
|
||||
second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
second.append('turn/start', { turn: 1 })
|
||||
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
expect(() => second.append('turn/start', { turn: 3 }))
|
||||
.toThrow(/expected turn 2, got 3/)
|
||||
|
||||
const outside = (await setup()).ctx.sessions.create()
|
||||
@@ -149,17 +148,16 @@ describe('session-log invariants', () => {
|
||||
expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow()
|
||||
expect(() => outside.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('enforces open-step identity and numbering', async () => {
|
||||
const wrongTurn = (await setup()).ctx.sessions.create()
|
||||
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
wrongTurn.append('turn/start', { turn: 1 })
|
||||
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
|
||||
|
||||
const nested = (await setup()).ctx.sessions.create()
|
||||
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
nested.append('turn/start', { turn: 1 })
|
||||
nested.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
|
||||
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
|
||||
@@ -179,7 +177,7 @@ describe('session-log invariants', () => {
|
||||
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
|
||||
|
||||
const skipped = (await setup()).ctx.sessions.create()
|
||||
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
skipped.append('turn/start', { turn: 1 })
|
||||
skipped.append('step/start', { turn: 1, step: 1 })
|
||||
skipped.append('step/end', { turn: 1, step: 1 })
|
||||
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
|
||||
@@ -188,7 +186,7 @@ describe('session-log invariants', () => {
|
||||
|
||||
it('requires step-scoped stream and tool events to name the open step', async () => {
|
||||
const chunk = (await setup()).ctx.sessions.create()
|
||||
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
chunk.append('turn/start', { turn: 1 })
|
||||
expect(() => chunk.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -196,7 +194,7 @@ describe('session-log invariants', () => {
|
||||
})).toThrow(/open is turn 1\/step null/)
|
||||
|
||||
const tool = (await setup()).ctx.sessions.create()
|
||||
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
tool.append('turn/start', { turn: 1 })
|
||||
tool.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => tool.append('tool/result', {
|
||||
turn: 1,
|
||||
@@ -212,7 +210,7 @@ describe('session-log invariants', () => {
|
||||
it('keeps fresh tool-result appends open-step checked', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -227,7 +225,7 @@ describe('session-log invariants', () => {
|
||||
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
|
||||
const { ctx } = await setup()
|
||||
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('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
@@ -248,7 +246,7 @@ describe('session-log invariants', () => {
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
expect(() => session.append('tool/result', {
|
||||
...original.data,
|
||||
message: freezeMessage({
|
||||
@@ -267,7 +265,7 @@ describe('session-log invariants', () => {
|
||||
it('rejects a tool-result replacement outside a turn', async () => {
|
||||
const { ctx } = await setup()
|
||||
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('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', {
|
||||
turn: 1,
|
||||
@@ -306,7 +304,7 @@ describe('session-log invariants', () => {
|
||||
it('allows not-started repair results and unresolved calls at step end', async () => {
|
||||
const repaired = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
repaired.append('turn/start', { turn: 1 })
|
||||
repaired.append('step/start', { turn: 1, step: 1 })
|
||||
repaired.append('tool/result', {
|
||||
turn: 1,
|
||||
@@ -324,7 +322,7 @@ describe('session-log invariants', () => {
|
||||
|
||||
const unresolved = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
unresolved.append('turn/start', { turn: 1 })
|
||||
unresolved.append('step/start', { turn: 1, step: 1 })
|
||||
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
unresolved.append('step/end', { turn: 1, step: 1 })
|
||||
@@ -335,7 +333,7 @@ describe('session-log invariants', () => {
|
||||
it('does not let a result in a later step satisfy an earlier call', async () => {
|
||||
const { ctx } = await setup()
|
||||
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('step/start', { turn: 1, step: 1 })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
@@ -354,22 +352,22 @@ describe('session-log invariants', () => {
|
||||
it('replays seeded sessions and tracks each session independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const badSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1 } },
|
||||
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2 } },
|
||||
]
|
||||
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
|
||||
|
||||
const a = ctx.sessions.create(SessionId('a'))
|
||||
const b = ctx.sessions.create(SessionId('b'))
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
a.append('turn/start', { turn: 1 })
|
||||
expect(() => b.append('turn/start', { turn: 1 }))
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
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('step/start', { turn: 1, step: 1 })
|
||||
await fiber.dispose()
|
||||
await ctx.plugin(SessionInvariant)
|
||||
@@ -378,18 +376,17 @@ describe('session-log invariants', () => {
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'h' },
|
||||
})).not.toThrow()
|
||||
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
expect(() => session.append('turn/start', { turn: 2 }))
|
||||
.toThrow(/turn 1 is still open/)
|
||||
})
|
||||
|
||||
it('removes all listeners when the companion is disposed', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await fiber.dispose()
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@ const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
|
||||
|
||||
// A non-message event (trace/replay data — must NOT affect derived history).
|
||||
const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
|
||||
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1 } }),
|
||||
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
|
||||
fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
|
||||
*/
|
||||
|
||||
const userTurnStart = (turn: number, seq: number): SessionEvent =>
|
||||
({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
|
||||
({ type: 'turn/start', seq, time: seq, data: { turn } })
|
||||
|
||||
describe('interruptedTurnClosers', () => {
|
||||
it('returns nothing for a balanced log (ends on turn/end)', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('foldRequestHeader', () => {
|
||||
it('returns the supplied baseline when no snapshot follows', () => {
|
||||
const from: EpochHeader = { config: CONFIG, system: 'baseline' }
|
||||
const unrelated: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
]
|
||||
expect(foldRequestHeader(unrelated)).toBeUndefined()
|
||||
expect(foldRequestHeader(unrelated, from)).toBe(from)
|
||||
@@ -53,7 +53,7 @@ describe('foldRequestHeader', () => {
|
||||
|
||||
it('takes the latest full snapshot and skips unrelated events', () => {
|
||||
const session = new Session(SessionId('fold'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('session dispatch carriers', () => {
|
||||
otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
|
||||
|
||||
const session = scope.ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
expect(heard).toEqual([
|
||||
`owner-created:${session.id}`,
|
||||
@@ -57,7 +57,7 @@ describe('session dispatch carriers', () => {
|
||||
scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
|
||||
|
||||
const bare = ctx.sessions.create()
|
||||
bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
bare.append('turn/start', { turn: 1 })
|
||||
expect(heard).toEqual(['global:turn/start'])
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, {
|
||||
findLastMessageTurnEnd,
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionEvent,
|
||||
@@ -22,7 +21,7 @@ describe('Session', () => {
|
||||
|
||||
it('derives message history from the event log', () => {
|
||||
const session = new Session(SessionId('s1'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -62,7 +61,7 @@ describe('Session', () => {
|
||||
// The max-tokens TurnEndReason variant carries no extra data, so it must
|
||||
// append and persist like any other reason (JSON-serializable, no fields).
|
||||
const session = new Session(SessionId('s1'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
|
||||
const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
|
||||
@@ -71,45 +70,9 @@ describe('Session', () => {
|
||||
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
|
||||
})
|
||||
|
||||
it('finds the latest message-turn outcome past later non-message turns', () => {
|
||||
const session = new Session(SessionId('message-turn-outcome'))
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'before' }],
|
||||
source: { kind: 'plugin', plugin: 'before' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
|
||||
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'bounded prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 3,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'after' }],
|
||||
source: { kind: 'plugin', plugin: 'after' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
|
||||
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
|
||||
})
|
||||
|
||||
it('round-trips the coarse aborted turn outcome', () => {
|
||||
const session = new Session(SessionId('aborted'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
expect(replayed.events).toEqual(session.events)
|
||||
@@ -121,7 +84,7 @@ describe('Session', () => {
|
||||
const legacy = [
|
||||
{
|
||||
type: 'turn/start', seq: 0, time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
},
|
||||
{
|
||||
type: 'turn/end', seq: 1, time: 2,
|
||||
@@ -169,7 +132,7 @@ describe('Session', () => {
|
||||
|
||||
it('replays identically from a seeded event log', () => {
|
||||
const original = new Session(SessionId('s3'))
|
||||
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('turn/start', { turn: 1 })
|
||||
original.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -346,13 +309,13 @@ describe('Session', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
})
|
||||
expect(boundary).toEqual({
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
})
|
||||
|
||||
const extended = snapshotSessionEvent({
|
||||
@@ -463,7 +426,7 @@ describe('Session', () => {
|
||||
|
||||
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
|
||||
const session = new Session(SessionId('s5b'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
// A widened SessionEventType bypasses the overload's conditional requirement,
|
||||
// so the runtime guard must still reject the missing surface marker.
|
||||
const widenedType = 'user/message' as SessionEventType
|
||||
@@ -492,7 +455,7 @@ describe('Session', () => {
|
||||
|
||||
it('validates seed events: rejects a non-contiguous seq', () => {
|
||||
const gapSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
||||
@@ -504,7 +467,7 @@ describe('Session', () => {
|
||||
// so a resume/fork would silently lose history. append() forbids this at
|
||||
// compile time; a raw seed must be rejected at runtime to match.
|
||||
const markerlessSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
|
||||
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
|
||||
}) },
|
||||
@@ -515,7 +478,7 @@ describe('Session', () => {
|
||||
|
||||
it('accepts a well-formed contiguous serializable seed', () => {
|
||||
const goodSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
|
||||
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
|
||||
}), surfaceOp: 'append' as const },
|
||||
@@ -530,7 +493,7 @@ describe('Session', () => {
|
||||
type: 'turn/start' as const,
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
|
||||
data: { turn: 1 },
|
||||
}
|
||||
const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
|
||||
let reads = 0
|
||||
@@ -606,7 +569,7 @@ describe('Session', () => {
|
||||
readonly type = 'turn/start' as const
|
||||
readonly seq = 0
|
||||
readonly time = 1
|
||||
readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
|
||||
readonly data = { turn: 1 }
|
||||
}
|
||||
const seed: SessionEvent[] = [new SeedEvent()]
|
||||
|
||||
@@ -619,7 +582,7 @@ describe('Session', () => {
|
||||
type: 'turn/start' as const,
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
|
||||
data: { turn: 1 },
|
||||
}) as unknown as SessionEvent
|
||||
|
||||
const session = new Session(SessionId('seed-null-prototype'), [event])
|
||||
@@ -701,7 +664,7 @@ describe('Session', () => {
|
||||
|
||||
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
|
||||
const seed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message' as const, seq: 1, time: 2, data: {
|
||||
id: MessageId('seed-input'),
|
||||
role: 'user' as const,
|
||||
@@ -852,14 +815,14 @@ describe('Session', () => {
|
||||
|
||||
expect(() => appendRaw(
|
||||
'turn/start',
|
||||
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
{ turn: 1 },
|
||||
{ surfaceOp: 'append' },
|
||||
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
|
||||
expect(session.events).toEqual([])
|
||||
@@ -870,13 +833,12 @@ describe('Session', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
}])
|
||||
const seededEvent = seeded.events[0]!
|
||||
if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
|
||||
expect(Object.isFrozen(seededEvent)).toBe(true)
|
||||
expect(Object.isFrozen(seededEvent.data)).toBe(true)
|
||||
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
|
||||
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
const appended = new Session(SessionId('append-frozen'))
|
||||
@@ -892,7 +854,7 @@ describe('Session', () => {
|
||||
|
||||
it('returns cached frozen event-array snapshots that do not grow after append', () => {
|
||||
const session = new Session(SessionId('events-snapshot'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const before = session.events
|
||||
const beforeEvent = before[0]!
|
||||
if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
|
||||
@@ -989,7 +951,7 @@ describe('Session', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
}
|
||||
const cases: unknown[] = [
|
||||
{ ...base, extra: true },
|
||||
@@ -1028,7 +990,7 @@ describe('SessionStore', () => {
|
||||
// may create an unrelated property with the old implementation's name,
|
||||
// but cannot suppress the durable event feed.
|
||||
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -1046,7 +1008,7 @@ describe('SessionStore', () => {
|
||||
const a = ctx.sessions.create(SessionId('fixed'))
|
||||
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
|
||||
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
a.append('turn/start', { turn: 1 })
|
||||
a.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -1295,7 +1257,7 @@ describe('SessionStore', () => {
|
||||
ctx.on('session/event', (_session, event) => void events.push(event))
|
||||
const session = ctx.sessions.create(SessionId('fixed'))
|
||||
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -1321,7 +1283,6 @@ describe('SessionStore', () => {
|
||||
expect(() => {
|
||||
appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
}).not.toThrow()
|
||||
expect(committedBeforeNotify).toBe(true)
|
||||
@@ -1360,14 +1321,12 @@ describe('SessionStore', () => {
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('reject first candidate')
|
||||
expect(session.events).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
|
||||
{ logLength: 0, frozen: true },
|
||||
@@ -1383,7 +1342,7 @@ describe('SessionStore', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'source' }],
|
||||
@@ -1438,7 +1397,6 @@ describe('SessionStore', () => {
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('dispatch instrumentation rejected the carrier')
|
||||
expect(session.events).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
@@ -1458,7 +1416,6 @@ describe('SessionStore', () => {
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(heard).toEqual([appended])
|
||||
@@ -1489,7 +1446,6 @@ describe('SessionStore', () => {
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
expect(session.events).toEqual([appended])
|
||||
@@ -1640,7 +1596,7 @@ describe('todo/write event', () => {
|
||||
|
||||
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
||||
const original = new Session(SessionId('t4'))
|
||||
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('turn/start', { turn: 1 })
|
||||
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
|
||||
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// Seeding a non-surface event with no surfaceOp must not throw.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
function surfaceSession(): Session {
|
||||
const s = new Session(SessionId('ss'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -93,7 +93,7 @@ describe('foldSurface provenance', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
sourceEventSeqs: [0],
|
||||
} as unknown as SessionEvent
|
||||
expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
|
||||
@@ -378,7 +378,7 @@ describe('SurfaceManager', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent
|
||||
|
||||
@@ -396,7 +396,7 @@ describe('SurfaceManager', () => {
|
||||
|
||||
it('empty surface yields empty nodes', () => {
|
||||
const s = new Session(SessionId('empty'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
@@ -665,7 +665,7 @@ describe('deriveMessages with surface', () => {
|
||||
|
||||
it('surface path skips non-surface events (chunks, boundaries)', () => {
|
||||
const s = new Session(SessionId('filter'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
|
||||
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
|
||||
s.append('user/message', createUserMessage({
|
||||
@@ -731,7 +731,7 @@ describe('deriveMessages with surface', () => {
|
||||
describe('Session.append surface opts', () => {
|
||||
it('records sourceEventSeqs and surfaceOp on the event', () => {
|
||||
const s = new Session(SessionId('opts'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
const event = s.append('assistant/message',
|
||||
{
|
||||
@@ -759,7 +759,7 @@ describe('Session.append surface opts', () => {
|
||||
// but _deriveOneMessage returns null for it, so the surface derivation path's
|
||||
// null-check is exercised — the node is on the surface yet produces no message.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 3, data: {
|
||||
turn: 1, step: 1,
|
||||
@@ -782,7 +782,7 @@ describe('Session.append surface opts', () => {
|
||||
|
||||
it('a non-surface event carries no surface fields', () => {
|
||||
const s = new Session(SessionId('noopts'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('turn/start', { turn: 1 })
|
||||
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
|
||||
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
|
||||
})
|
||||
@@ -816,7 +816,7 @@ describe('Session.append surface opts', () => {
|
||||
}
|
||||
expect(isSurfaceEvent(noMarker)).toBe(false)
|
||||
// A non-surface type is rejected too (the type gate).
|
||||
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
|
||||
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } }
|
||||
expect(isSurfaceEvent(boundary)).toBe(false)
|
||||
// A properly-marked surface event narrows.
|
||||
const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
|
||||
@@ -866,7 +866,7 @@ describe('surface type guards', () => {
|
||||
describe('SurfaceManager.replaceGeneration', () => {
|
||||
it('folds the pending log delta on access and counts replaces', () => {
|
||||
const s = new Session(SessionId('gen'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('turn/start', { turn: 1 })
|
||||
s.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('tool-pipeline invariants', () => {
|
||||
arguments: {},
|
||||
}
|
||||
expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
@@ -107,7 +107,7 @@ describe('tool-pipeline invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
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/code-dispatch', {
|
||||
parentCallId: CallId('parent'),
|
||||
subCallId: CallId('child'),
|
||||
|
||||
@@ -163,7 +163,6 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
const session = ctx.sessions.create(SessionId('configured-title-limits'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'One two three four' }],
|
||||
@@ -206,8 +205,8 @@ describe('dsh-agent-spine-demo bundle', () => {
|
||||
it('mounts package companions and forwards invariant selection config', async () => {
|
||||
const nestedTurn = (ctx: Context): void => {
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
}
|
||||
|
||||
const enabled = await mount({ workspaceContext: false })
|
||||
|
||||
@@ -388,14 +388,14 @@ describe('runOneShot and executeCli', () => {
|
||||
if (subject !== agent || injected) return
|
||||
injected = true
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'startup injection' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
other.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } })
|
||||
other.append('turn/start', { turn: 1 })
|
||||
other.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
})
|
||||
const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
|
||||
const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent)
|
||||
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 1, result: 'streamed' })
|
||||
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } })
|
||||
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1 } })
|
||||
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
|
||||
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
|
||||
expect(events.some(event => event.type === 'user/message'
|
||||
@@ -510,11 +510,9 @@ describe('formatTurnFailure', () => {
|
||||
it('diagnoses every durable reason and preserves merge-extensible unknowns', () => {
|
||||
const cases: [TurnEndReason, string][] = [
|
||||
[{ kind: 'completed' }, 'completed'],
|
||||
[{ kind: 'aborted' }, 'was aborted'],
|
||||
[{ kind: 'aborted' }, 'was aborted'],
|
||||
[{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'],
|
||||
[{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'],
|
||||
[{ kind: 'disposed' }, 'was disposed'],
|
||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'was aborted'],
|
||||
[{ kind: 'error', error: new Error('bad') }, 'failed: bad'],
|
||||
[{ kind: 'error', error: { message: 'provider bad', code: 'SERVER' } }, 'provider bad'],
|
||||
[{ kind: 'max-tokens' }, 'output-token limit'],
|
||||
[{ kind: 'interrupted' }, 'persistence recovery'],
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ interface Harness {
|
||||
function appendInjection(session: Session, input: UserMessage): void {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source: input.source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
@@ -37,7 +37,6 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) { appendInjection(session, input) },
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Context } from 'cordis'
|
||||
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
|
||||
import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { classifyGoalRound } from './outcome.ts'
|
||||
import type { GoalRoundOutcome } from './outcome.ts'
|
||||
@@ -33,6 +33,7 @@ interface RoundIdentity {
|
||||
|
||||
/** One queued or admitted attempt, retained until its physical turn settles. */
|
||||
interface RoundAttempt extends RoundIdentity {
|
||||
readonly messageId: MessageId
|
||||
readonly content: ContentBlock[]
|
||||
phase: 'queued' | 'admitted'
|
||||
turn: number | undefined
|
||||
@@ -214,10 +215,15 @@ export function apply(ctx: Context): void {
|
||||
|
||||
const round = goal.roundsStarted + 1
|
||||
const content = renderGoalRoundPrompt(goal, round)
|
||||
const message = createUserMessage({
|
||||
content,
|
||||
source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
|
||||
})
|
||||
const reservation: RoundAttempt = {
|
||||
goalId: goal.id,
|
||||
revision: goal.revision,
|
||||
round,
|
||||
messageId: message.id,
|
||||
content,
|
||||
phase: 'queued',
|
||||
turn: undefined,
|
||||
@@ -226,7 +232,7 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
state.attempt = reservation
|
||||
try {
|
||||
agent.followup(createUserMessage({ content, source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round } }))
|
||||
agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
state.attempt = undefined
|
||||
ctx.logger.warn(`goal-session: could not queue round ${round} for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
@@ -277,9 +283,8 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
}
|
||||
|
||||
// One composite effect owns every listener and the quiescent close. Cordis
|
||||
// unloads sibling effects concurrently; nesting makes the close run first
|
||||
// and keeps the admission fence installed until its drain settles.
|
||||
// One composite effect keeps the admission fence installed until this
|
||||
// plugin's own scheduling tasks settle.
|
||||
ctx.effect(function* () {
|
||||
/** Mark a post-turn persistence failure before idle scheduling can run. */
|
||||
ctx.on('agent/error', (agent, turn) => {
|
||||
@@ -304,40 +309,21 @@ export function apply(ctx: Context): void {
|
||||
const state = stateFor(agent)
|
||||
if (status === 'idle') {
|
||||
state.competingQueued = false
|
||||
const attempt = state.attempt
|
||||
const goal = currentGoal(state)
|
||||
if (attempt !== undefined && attempt.turn === undefined && attempt.reason === undefined
|
||||
&& goal?.phase === 'active' && goal.activation === 'armed') {
|
||||
state.attempt = undefined
|
||||
try {
|
||||
applyOutcome(state, goal, { kind: 'pause', reason: 'cancelled' })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
}
|
||||
requestDrive(state)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (agent, cause) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
state.competingQueued = false
|
||||
const goal = currentGoal(state)
|
||||
if (goal?.phase === 'active' && goal.activation === 'armed') {
|
||||
if (attempt === undefined) {
|
||||
disarm(state)
|
||||
return
|
||||
}
|
||||
// An admitted round closes durably as aborted; retain it so the normal
|
||||
// turn outcome path appends pause after cancellation reaches idle.
|
||||
// Pausing here would stage context into the active outbox only for this
|
||||
// same cancel() call to discard it.
|
||||
if (attempt.turn !== undefined || attempt.phase === 'admitted') return
|
||||
state.attempt = undefined
|
||||
try {
|
||||
applyOutcome(state, goal, { kind: 'pause', reason: cause.kind })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
ctx.on('goal/changed', (agent) => {
|
||||
const state = stateFor(agent)
|
||||
state.needsCheckpoint = true
|
||||
@@ -349,35 +335,22 @@ export function apply(ctx: Context): void {
|
||||
if (agent === undefined || agent.session !== session) return
|
||||
const state = stateFor(agent)
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
case 'agent/inbox/added': {
|
||||
const attempt = state.attempt
|
||||
const { content, source } = event.data
|
||||
if (attempt !== undefined && sameQueued(content, source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
return
|
||||
}
|
||||
case 'turn/start': {
|
||||
state.openTurn = event.data.turn
|
||||
switch (event.data.trigger.kind) {
|
||||
case 'message':
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
|
||||
&& sameRound(event.data.trigger.source, state.attempt)) {
|
||||
state.attempt.turn = event.data.turn
|
||||
}
|
||||
return
|
||||
case 'retry':
|
||||
// A recovery policy (llm-retry) closed the round's failed turn
|
||||
// and reopened its history: the attempt rides the retry turn,
|
||||
// and the failed turn's provisional reason no longer settles
|
||||
// the round — the retry's own outcome does.
|
||||
if (state.attempt !== undefined && state.attempt.reason !== undefined
|
||||
&& state.attempt.reason.kind === 'error') {
|
||||
state.attempt.turn = event.data.turn
|
||||
state.attempt.reason = undefined
|
||||
}
|
||||
return
|
||||
default:
|
||||
// Injection and merge-extensible plugin triggers cannot admit a queued goal message.
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'user/message':
|
||||
if (state.attempt !== undefined && isGoalRoundSource(event.data.source)
|
||||
&& sameRound(event.data.source, state.attempt)) {
|
||||
if (state.attempt !== undefined && event.data.id === state.attempt.messageId) {
|
||||
state.attempt.phase = 'admitted'
|
||||
/* v8 ignore next -- this driver's admitted message always follows its observed turn/start */
|
||||
/* v8 ignore next -- the loop logs admitted input inside an open turn */
|
||||
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
|
||||
}
|
||||
return
|
||||
@@ -407,8 +380,10 @@ export function apply(ctx: Context): void {
|
||||
&& source.round === goal.roundsStarted + 1
|
||||
}
|
||||
|
||||
ctx.on('agent/prompt-submit', async (agent, message, _signal, next): Promise<PromptDecision> => {
|
||||
const { content, source } = message
|
||||
ctx.on('agent/prompt-submit', async (agent, messages, _signal, next): Promise<PromptDecision> => {
|
||||
const submitted = messages.find(message => isGoalRoundSource(message.source))
|
||||
if (submitted === undefined) return next()
|
||||
const { content, source } = submitted
|
||||
if (!isGoalRoundSource(source)) return next()
|
||||
const state = stateFor(agent)
|
||||
let valid = false
|
||||
@@ -494,7 +469,6 @@ export function apply(ctx: Context): void {
|
||||
if (attempt.phase === 'admitted' && state.agent.status === 'running') {
|
||||
state.agent.cancel({ kind: 'parent' })
|
||||
}
|
||||
waits.push(state.agent.whenIdle())
|
||||
}
|
||||
if (state.run !== undefined) waits.push(state.run)
|
||||
}
|
||||
|
||||
@@ -28,15 +28,17 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal
|
||||
case 'aborted':
|
||||
return { kind: 'pause', reason: 'cancelled' }
|
||||
case 'error': {
|
||||
const { code, message } = reason.failure ?? reason
|
||||
const error = reason.error
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error
|
||||
? error.code
|
||||
: undefined
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return code === 'RATE_LIMIT' || code === 'QUOTA'
|
||||
? { kind: 'blocked', code: 'usage-limited', message }
|
||||
: { kind: 'blocked', code: 'turn-error', message }
|
||||
}
|
||||
case 'max-tokens':
|
||||
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }
|
||||
case 'disposed':
|
||||
return { kind: 'disarm', reason: 'disposed' }
|
||||
case 'interrupted':
|
||||
return { kind: 'disarm', reason: 'interrupted' }
|
||||
// TurnEndReason is merge-extensible. An unknown producer cannot opt into
|
||||
|
||||
@@ -12,13 +12,6 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import * as goalSession from '../src/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface TurnTriggerMap {
|
||||
/** Test-only plugin turn with no message source. */
|
||||
'test-metadata': { kind: 'test-metadata' }
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
|
||||
|
||||
/** Small request-recording adapter with controllable failure and cancellation. */
|
||||
@@ -334,31 +327,6 @@ describe('same-session goal driving', () => {
|
||||
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
|
||||
})
|
||||
|
||||
it('ignores plugin-owned turn triggers while a goal round is queued', async () => {
|
||||
const test = await harness([textResponse('goal answer')])
|
||||
const warnings: string[] = []
|
||||
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
|
||||
let inserted = false
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
|
||||
const turn = (lastStart?.data.turn ?? 0) + 1
|
||||
agent.session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'test-metadata' },
|
||||
})
|
||||
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
})
|
||||
test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 })
|
||||
|
||||
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
|
||||
|
||||
expect(inserted).toBe(true)
|
||||
expect(test.adapter.requests).toHaveLength(1)
|
||||
expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false)
|
||||
})
|
||||
|
||||
it('makes a reserved round stale when a listener queues human work behind it', async () => {
|
||||
const test = await harness([textResponse('human batch'), textResponse('later goal')])
|
||||
let inserted = false
|
||||
@@ -908,8 +876,7 @@ describe('same-session goal driving', () => {
|
||||
let queued = false
|
||||
test.ctx.on('session/event', (session, event) => {
|
||||
if (session !== test.agent.session || queued) return
|
||||
if (event.type === 'turn/start' && event.data.trigger.kind === 'message'
|
||||
&& event.data.trigger.source.kind === 'goal') {
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'goal') {
|
||||
queued = true
|
||||
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human interleaved' }], source: { kind: 'user' } }))
|
||||
}
|
||||
@@ -997,7 +964,6 @@ describe('same-session goal driving', () => {
|
||||
const orphan = test.ctx.sessions.create(SessionId('goal-session-orphan'))
|
||||
orphan.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } },
|
||||
})
|
||||
orphan.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function view(roundsStarted: number): GoalView {
|
||||
}
|
||||
|
||||
function appendChange(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
@@ -51,7 +51,7 @@ function appendChange(session: Session): void {
|
||||
|
||||
function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void {
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content, source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -82,7 +82,7 @@ describe('goal-session prompt invariants', () => {
|
||||
ctx.sessions.create(SessionId('goal-session-invariant-dispatch'))
|
||||
|
||||
const userSource = { kind: 'user' } as const
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } })
|
||||
session.append('turn/start', { turn: 4 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'ordinary human message' }],
|
||||
source: userSource,
|
||||
@@ -90,7 +90,7 @@ describe('goal-session prompt invariants', () => {
|
||||
session.append('turn/end', { turn: 4, reason: { kind: 'completed' } })
|
||||
|
||||
const stateSource = { ...changeSource, round: 0 } as const
|
||||
session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } })
|
||||
session.append('turn/start', { turn: 5 })
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'round zero is not a driver continuation' }],
|
||||
@@ -114,7 +114,7 @@ describe('goal-session prompt invariants', () => {
|
||||
it('rejects a goal round without a reconstructable active goal', async () => {
|
||||
const { session } = await mount()
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
@@ -128,7 +128,7 @@ describe('goal-session prompt invariants', () => {
|
||||
|
||||
it('attributes an invalid durable prefix during late loading', async () => {
|
||||
const { ctx, session } = await mount(true)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'counterfeit goal state' }],
|
||||
source: changeSource,
|
||||
|
||||
@@ -47,7 +47,6 @@ function stubAgentForSession(session: Session): StubAgent {
|
||||
ctx: new Context(),
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
@@ -88,7 +87,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
|
||||
function appendRound(session: Session, ref: GoalRef, round: number): void {
|
||||
const source = { kind: 'goal', goalId: ref.id, revision: ref.revision, round } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `round ${round}` }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -503,7 +502,7 @@ describe('GoalService mutations', () => {
|
||||
}
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change), source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -584,7 +583,7 @@ describe('goal replay validation', () => {
|
||||
change,
|
||||
}
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: overrides.content ?? renderGoalChange(change),
|
||||
source,
|
||||
@@ -640,7 +639,7 @@ describe('goal replay validation', () => {
|
||||
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
|
||||
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'ordinary' }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -782,7 +781,7 @@ describe('goal replay validation', () => {
|
||||
const session = new Session(SessionId('goal-source-without-meta'))
|
||||
const source = { kind: 'goal', goalId: GoalId('goal-missing-meta'), revision: 1, round: 0 } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'missing' }], source,
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -850,7 +849,7 @@ describe('goal replay validation', () => {
|
||||
}
|
||||
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change: clear } as const
|
||||
const turn = nextTurn(session)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(clear), source,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('goal stream invariants', () => {
|
||||
it('accepts canonical goal snapshots and sequential admitted rounds', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
@@ -70,7 +70,7 @@ describe('goal stream invariants', () => {
|
||||
it('rejects model-visible drift before committing it and keeps the fold reusable', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(() => {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'counterfeit' }],
|
||||
@@ -93,7 +93,7 @@ describe('goal stream invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('goal-invariant-late-load'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: renderGoalChange(change),
|
||||
source: changeSource,
|
||||
|
||||
@@ -38,7 +38,6 @@ function liveAgent(ctx: Context, session: Session): Agent {
|
||||
ctx,
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return false },
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input: UserMessage) {
|
||||
@@ -145,7 +144,7 @@ describe('goal projection unit', () => {
|
||||
|
||||
// A non-message event (the registry drives EVERY committed event through
|
||||
// apply): early same-reference return.
|
||||
const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never
|
||||
const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never
|
||||
expect(applyGoalProjection(state, turnStart)).toBe(state)
|
||||
|
||||
// A round-zero goal source whose change carries a foreign kind: same posture.
|
||||
|
||||
@@ -32,7 +32,6 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
||||
get status() { return status },
|
||||
get acceptsNextStep() { return status === 'running' },
|
||||
ctx: new Context(),
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject(input) {
|
||||
@@ -49,7 +48,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb
|
||||
const turn = stub.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.reduce((max, event) => Math.max(max, event.data.turn), 0) + 1
|
||||
stub.session.append('turn/start', { turn, trigger: { kind: 'message', source } })
|
||||
stub.session.append('turn/start', { turn })
|
||||
stub.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source,
|
||||
|
||||
@@ -30,7 +30,7 @@ const result = (overrides: Record<string, unknown> = {}) => ({
|
||||
})
|
||||
|
||||
function startTurn(session: Session, turn = 1): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
}
|
||||
|
||||
describe('hook-protocol invariants', () => {
|
||||
@@ -49,7 +49,7 @@ describe('hook-protocol invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
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('hook/invoked', invoked())
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(HookInvariant)
|
||||
@@ -63,7 +63,7 @@ describe('hook-protocol invariants', () => {
|
||||
expect(() => {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
})
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'hook/invoked', seq: 1, time: 1, data: invoked(),
|
||||
|
||||
@@ -197,6 +197,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return [ours, ...theirs ?? []]
|
||||
}
|
||||
|
||||
/** Append hook context to an admitted inbox batch. */
|
||||
function appendPromptContext(theirs: UserMessage[], ours: UserMessage): UserMessage[] {
|
||||
return [...theirs, ours]
|
||||
}
|
||||
|
||||
// SessionStart injects context when its detached hook resolves; a slow hook
|
||||
// may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
@@ -213,8 +218,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
|
||||
// --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
|
||||
// matcher subject (CC ignores matchers for this event). ---
|
||||
ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise<PromptDecision> => {
|
||||
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, message.content), { agent, signal })
|
||||
ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise<PromptDecision> => {
|
||||
const content = messages.flatMap(message => message.content)
|
||||
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, signal })
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
|
||||
}
|
||||
@@ -225,8 +231,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (!ours || downstream.kind !== 'allow') return downstream
|
||||
return {
|
||||
kind: 'allow',
|
||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||
additionalContexts: prependContext(ours, downstream.additionalContexts),
|
||||
messages: appendPromptContext(downstream.messages, ours),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -182,6 +182,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return [ours, ...theirs ?? []]
|
||||
}
|
||||
|
||||
/** Append hook context to an admitted inbox batch. */
|
||||
function appendPromptContext(theirs: UserMessage[], ours: UserMessage): UserMessage[] {
|
||||
return [...theirs, ours]
|
||||
}
|
||||
|
||||
// SessionStart injects plain stdout when its detached hook resolves; a slow
|
||||
// hook may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
@@ -196,11 +201,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
// UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask.
|
||||
ctx.on('agent/prompt-submit', async (agent, message, signal, next): Promise<PromptDecision> => {
|
||||
ctx.on('agent/prompt-submit', async (agent, messages, signal, next): Promise<PromptDecision> => {
|
||||
const payload = {
|
||||
...base(ctx, agent, 'UserPromptSubmit', model),
|
||||
turn_id: String(lastTurn(agent) + 1),
|
||||
prompt: blocksToText(message.content),
|
||||
prompt: blocksToText(messages.flatMap(message => message.content)),
|
||||
}
|
||||
const merged = await runPoint('UserPromptSubmit', '', payload, { agent, plainStdoutAsContext: true, signal })
|
||||
/* jscpd:ignore-start */
|
||||
@@ -212,8 +217,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (!ours || downstream.kind !== 'allow') return downstream
|
||||
return {
|
||||
kind: 'allow',
|
||||
...downstream.content !== undefined ? { content: downstream.content } : {},
|
||||
additionalContexts: prependContext(ours, downstream.additionalContexts),
|
||||
messages: appendPromptContext(downstream.messages, ours),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Provider-routed model-request retry policy on the agent loop's closed-step
|
||||
* Provider-routed model-request retry policy on the agent loop's request
|
||||
* recovery seam. Each scheduled retry is durable before its cancellable wait.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-retry
|
||||
@@ -7,14 +7,13 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, RequestError, RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { providerForClosedStep } from './history.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Durable, non-surface record of one provider-routed retry scheduled after a closed failed step. */
|
||||
/** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */
|
||||
'llm/retry': {
|
||||
turn: number
|
||||
step: number
|
||||
@@ -172,24 +171,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
|
||||
async function recover(
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
_error: RequestError,
|
||||
failure: LlmFailure,
|
||||
priorFailures: readonly LlmFailure[],
|
||||
policy: ResolvedRetryPolicy | undefined,
|
||||
context: RequestFailureContext,
|
||||
signal: AbortSignal,
|
||||
next: () => Promise<RequestErrorAction>,
|
||||
): Promise<RequestErrorAction> {
|
||||
const { turn, step, provider, failure, retryPolicy: policy } = context
|
||||
if (policy === undefined) return next()
|
||||
// The call-local policy belongs to the registration that served this
|
||||
// failure. Recover only the durable provider identity from the header;
|
||||
// downstream recovery may append later state before an always fallback.
|
||||
const provider = providerForClosedStep(agent.session.events, turn, step)
|
||||
/* v8 ignore next 3 -- agent-loop closes only steps whose request header was recorded */
|
||||
if (provider === undefined) {
|
||||
throw new Error(`llm-retry: no request provider for closed turn ${turn}/step ${step}`)
|
||||
}
|
||||
if (policy.mode === 'always') {
|
||||
if (signal.aborted || lifetime.signal.aborted) return
|
||||
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
|
||||
@@ -211,11 +198,10 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
}
|
||||
|
||||
const policyKey = retryPolicyKey(policy)
|
||||
const firstPriorTurn = turn - priorFailures.length
|
||||
const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>
|
||||
event.type === 'llm/retry'
|
||||
&& event.data.turn >= firstPriorTurn
|
||||
&& event.data.turn < turn
|
||||
&& event.data.turn === turn
|
||||
&& event.data.step === step
|
||||
&& event.data.provider === provider
|
||||
&& event.data.policyKey === policyKey,
|
||||
)
|
||||
@@ -241,12 +227,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
|
||||
const disposeListener = ctx.on('agent/request-error', (
|
||||
agent: Agent,
|
||||
turn: number,
|
||||
step: number,
|
||||
error: RequestError,
|
||||
failure: LlmFailure,
|
||||
priorFailures: readonly LlmFailure[],
|
||||
policy: ResolvedRetryPolicy | undefined,
|
||||
context: RequestFailureContext,
|
||||
signal: AbortSignal,
|
||||
next: () => Promise<RequestErrorAction>,
|
||||
) => {
|
||||
@@ -254,7 +235,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
||||
// removed. Lifetime cancellation must prevent that stale callback from
|
||||
// entering a downstream policy after disposal.
|
||||
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined)
|
||||
return track(recover(agent, turn, step, error, failure, priorFailures, policy, signal, next))
|
||||
return track(recover(agent, context, signal, next))
|
||||
})
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
|
||||
@@ -41,34 +41,6 @@ function validateFailure(value: unknown, fail: InvariantFailure): asserts value
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the first turn in the structured-failure retry chain containing `turn`. */
|
||||
function retryChainStart(history: readonly SessionEvent[], turn: number): number {
|
||||
let startIndex = history.findLastIndex(
|
||||
event => event.type === 'turn/start' && event.data.turn === turn,
|
||||
)
|
||||
while (startIndex >= 0) {
|
||||
const start = history[startIndex]
|
||||
if (start?.type !== 'turn/start' || start.data.trigger.kind !== 'retry') break
|
||||
|
||||
let endIndex = startIndex - 1
|
||||
while (endIndex >= 0 && history[endIndex]?.type !== 'turn/end') endIndex -= 1
|
||||
const end = history[endIndex]
|
||||
if (end?.type !== 'turn/end'
|
||||
|| end.data.reason.kind !== 'error'
|
||||
|| end.data.reason.failure === undefined) break
|
||||
|
||||
const previousStart = history.findLastIndex(
|
||||
(event, index) =>
|
||||
index < endIndex
|
||||
&& event.type === 'turn/start'
|
||||
&& event.data.turn === end.data.turn,
|
||||
)
|
||||
if (previousStart < 0) break
|
||||
startIndex = previousStart
|
||||
}
|
||||
return startIndex
|
||||
}
|
||||
|
||||
/** Validate one retry record against the open turn and most recently closed step. */
|
||||
function validateRetry(
|
||||
history: readonly SessionEvent[],
|
||||
@@ -139,7 +111,9 @@ function validateRetry(
|
||||
fail(`llm/retry provider ${provider} does not match the failed request provider ${String(routedProvider)}`)
|
||||
}
|
||||
|
||||
const chainStart = retryChainStart(history, turn)
|
||||
const chainStart = history.findLastIndex(
|
||||
prior => prior.type === 'turn/start' && prior.data.turn === turn,
|
||||
)
|
||||
const chain = history.slice(Math.max(chainStart, 0))
|
||||
const lastSuccess = chain.findLastIndex(prior => prior.type === 'assistant/message')
|
||||
const chainRetries = chain.slice(lastSuccess + 1)
|
||||
|
||||
@@ -17,7 +17,7 @@ async function setup(): Promise<Context> {
|
||||
|
||||
function closeStep(ctx: Context, id: string, turn = 1, step = 1) {
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('step/start', { turn, step })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
@@ -28,7 +28,7 @@ function closeStep(ctx: Context, id: string, turn = 1, step = 1) {
|
||||
}
|
||||
|
||||
function appendRetryTurn(session: Session, turn: number) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'retry' } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
@@ -73,7 +73,7 @@ describe('llm-retry invariants', () => {
|
||||
expect(() => {
|
||||
session.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
session.append('llm/retry', {
|
||||
@@ -169,14 +169,14 @@ describe('llm-retry invariants', () => {
|
||||
}).toThrow(/open turn is 1/)
|
||||
|
||||
const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step'))
|
||||
openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
openStep.append('turn/start', { turn: 1 })
|
||||
openStep.append('step/start', { turn: 1, step: 1 })
|
||||
expect(() => {
|
||||
openStep.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
}).toThrow(/step 1 is still open/)
|
||||
|
||||
const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step'))
|
||||
noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
noStep.append('turn/start', { turn: 1 })
|
||||
expect(() => {
|
||||
noStep.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
}).toThrow(/latest closed step is undefined/)
|
||||
@@ -208,7 +208,7 @@ describe('llm-retry invariants', () => {
|
||||
const mismatch = closeStep(ctx, 'retry-invariant-numbering')
|
||||
mismatch.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
mismatch.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
mismatch.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
mismatch.append('turn/start', { turn: 2 })
|
||||
mismatch.append('step/start', { turn: 2, step: 1 })
|
||||
mismatch.append('step/end', { turn: 2, step: 1 })
|
||||
expect(() => {
|
||||
@@ -218,7 +218,7 @@ describe('llm-retry invariants', () => {
|
||||
const reset = closeStep(ctx, 'retry-invariant-reset')
|
||||
reset.append('llm/retry', { turn: 1, step: 1, ...normal })
|
||||
reset.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
|
||||
reset.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
|
||||
reset.append('turn/start', { turn: 2 })
|
||||
reset.append('step/start', { turn: 2, step: 1 })
|
||||
reset.append('assistant/message', {
|
||||
turn: 2,
|
||||
@@ -234,7 +234,7 @@ describe('llm-retry invariants', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
reset.append('step/end', { turn: 2, step: 1 })
|
||||
reset.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
reset.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
reset.append('turn/start', { turn: 3 })
|
||||
reset.append('step/start', { turn: 3, step: 1 })
|
||||
reset.append('step/end', { turn: 3, step: 1 })
|
||||
expect(() => {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
|
||||
const ctx = await backend(kind)
|
||||
try {
|
||||
const session = ctx.sessions.create(SessionId(`retry-${kind}`))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'mock', model: 'mock' } },
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
||||
README.md: d343449d1530bf70a3a8c57f883894e29c42d18f
|
||||
README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12
|
||||
README.md: dc7499a6854fe9a45c1297aa2a1a67aea92eaf6f
|
||||
README.zh.md: 1f5850b6e73c067aa554636d33bfd9194a4410f3
|
||||
|
||||
@@ -16,10 +16,10 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize an adapter-configured default without clamping.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration as one cancellable, one-shot call.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration plus immutable retry policy as one cancellable, one-shot call.
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
|
||||
|
||||
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
|
||||
`LlmService` normalizes failures from final adapter selection, synchronous dispatch, iterator construction, and iteration into the stream protocol's single terminal form: `finish { kind: 'error' | 'aborted', failure }`. A failure after partial deltas may leave content blocks open; consumers discard that incomplete output. Errors from `llm/stream` middleware, nested calls, adapter cleanup, and downstream consumers remain thrown because they are plugin or consumer failures rather than model-request outcomes. A prepared call exposes the immutable retry policy captured with its exact adapter registration; a route handled entirely by middleware has no serving policy.
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
|
||||
@@ -44,7 +44,7 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum.
|
||||
|
||||
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
@@ -67,7 +67,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
|
||||
### Real adapters
|
||||
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale.
|
||||
Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses direct fetch with `eventsource-parser` SSE framing for the `deepseek` route, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) dynamically resolves configured provider/model pairs through `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish and tool arguments remain raw strings. Adapter implementations may throw or emit a failure finish internally; `LlmService` exposes both as a terminal failure finish. See [the twin LLM adapters](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the adapter rationale and [the terminal-failure decision](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md) for the service boundary.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 解析配置,并将其当前适配器注册与不可变重试策略捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。
|
||||
|
||||
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。
|
||||
`LlmService` 会把最终适配器选择、同步 dispatch、iterator 构造与迭代产生的失败规范化为流协议的单一终止形式:`finish { kind: 'error' | 'aborted', failure }`。部分 delta 之后的失败可能留下未关闭内容块;消费方会丢弃这部分不完整输出。`llm/stream` middleware、嵌套调用、适配器清理和下游消费方的错误仍会抛出,因为它们属于插件或消费方失败,而非模型请求结果。准备完成的调用会公开随其确切适配器注册捕获的不可变重试策略;完全由 middleware 处理的路由没有服务策略。
|
||||
|
||||
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带提供方/模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加支持它的适配器/UI/压缩实现。
|
||||
|
||||
流式输出是原始 chunk 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。
|
||||
流式输出是原始 chunk 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 抵达消费方;运行失败使用其中的 `error` 或 `aborted` reason,不再跨 stream API 抛出。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。
|
||||
|
||||
### 调用配置(`call-config.ts`)
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
### 真实适配器
|
||||
|
||||
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `StreamChunk` 约定,定义见 `types.ts`:usage 先于 finish,工具参数保持原始字符串,错误使用两种已批准路径之一。设计理由见 [双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md)。
|
||||
两个适配器使用不同内部机制实现 `LlmAdapter`:[`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) 针对 `deepseek` 路由使用直接 fetch 加 `eventsource-parser` SSE 分帧,[`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) 则通过 `@earendil-works/pi-ai` 动态解析已配置提供方/模型对。两者都遵循 `types.ts` 中的 `StreamChunk` 约定:usage 先于 finish,工具参数保持原始字符串。适配器实现内部可以抛出或发出失败 finish;`LlmService` 会将两者都作为终止失败 finish 暴露。适配器设计理由见[双 LLM 适配器](../../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md),服务边界见[终止失败决策](../../../.agents/notes/implemented/architecture/2026-07-29-terminal-llm-stream-failures.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,67 +1,40 @@
|
||||
/**
|
||||
* Private provider-failure tagging shared by `LlmService` and its consumers.
|
||||
* Normalization for values thrown by a final LLM adapter boundary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/adapter-failure
|
||||
*/
|
||||
|
||||
import { HarnessError } from './error.ts'
|
||||
import type { LlmFailure, StreamChunk } from './types.ts'
|
||||
import type { ResolvedRetryPolicy } from './retry-policy.ts'
|
||||
|
||||
/** Call-local facts captured when one model call enters its final adapter boundary. */
|
||||
export interface AdapterFailureScope {
|
||||
/** Errors and normalized facts proven to originate in this call's final adapter boundary. */
|
||||
readonly failures: WeakMap<Error, LlmFailure>
|
||||
/** Immutable policy of the exact adapter registration selected for this call. */
|
||||
retryPolicy?: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */
|
||||
const adapterFailureScopes = new WeakMap<AsyncIterable<StreamChunk>, AdapterFailureScope>()
|
||||
import type { LlmFailure } from './types.ts'
|
||||
|
||||
/**
|
||||
* Bind one call's adapter-failure scope to a unique returned stream handle.
|
||||
* @param stream - the waterfall-selected stream for this call.
|
||||
* @param failures - errors tagged by this call's final adapter boundary.
|
||||
* @returns a unique stream handle that delegates iteration to `stream`.
|
||||
* Detach serializable provider facts from a value thrown by an adapter.
|
||||
* @param value - arbitrary value thrown during adapter dispatch or iteration.
|
||||
* @returns immutable provider-neutral facts suitable for a terminal finish chunk.
|
||||
* @internal
|
||||
*/
|
||||
export function bindAdapterFailureScope(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
failures: AdapterFailureScope,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const call = {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
return stream[Symbol.asyncIterator]()
|
||||
},
|
||||
}
|
||||
adapterFailureScopes.set(call, failures)
|
||||
return call
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve an adapter's Error identity while tagging its provider origin.
|
||||
* @param failures - the call-local final-adapter failure scope.
|
||||
* @param value - arbitrary value thrown by adapter dispatch or iteration.
|
||||
* @returns the original Error, or a coded Error wrapping a non-Error throw.
|
||||
* @internal
|
||||
*/
|
||||
export function markLlmAdapterFailure(
|
||||
failures: AdapterFailureScope,
|
||||
value: unknown,
|
||||
): Error & { code?: string } {
|
||||
export function normalizeLlmFailure(value: unknown): LlmFailure {
|
||||
const error = value instanceof Error
|
||||
? value as Error & { code?: string }
|
||||
: new HarnessError(String(value), 'UNKNOWN', { cause: value })
|
||||
? value
|
||||
: new HarnessError(thrownMessage(value), 'UNKNOWN', { cause: value })
|
||||
// Cross-package copies preserve own data but not class identity. Trust the
|
||||
// carried facts only when both own properties agree after validation.
|
||||
const carried = ownFailureSnapshot(error)
|
||||
const failure = carried !== undefined && carried.code === ownErrorCode(error) ? carried : Object.freeze({
|
||||
if (carried !== undefined && carried.code === ownErrorCode(error)) return carried
|
||||
return Object.freeze({
|
||||
message: errorMessage(error),
|
||||
code: harnessErrorCode(error),
|
||||
})
|
||||
failures.failures.set(error, failure)
|
||||
return error
|
||||
}
|
||||
|
||||
/** Render a non-Error throw without letting hostile coercion escape normalization. */
|
||||
function thrownMessage(value: unknown): string {
|
||||
try {
|
||||
const message = String(value)
|
||||
return message.length > 0 ? message : 'LLM adapter failed'
|
||||
} catch (_hostileThrownValue) {
|
||||
return 'LLM adapter failed'
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a foreign error's own data-backed `code` without invoking accessors. */
|
||||
@@ -129,46 +102,3 @@ function errorMessage(error: Error): string {
|
||||
function harnessErrorCode(error: Error): string {
|
||||
return error instanceof HarnessError ? error.code : 'UNKNOWN'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure came from final adapter dispatch, iterator construction,
|
||||
* or iteration for the call represented by the exact returned stream handle.
|
||||
* @param stream - the exact stream returned by the model call being classified.
|
||||
* @param value - arbitrary failure caught by a model-call consumer.
|
||||
* @returns true only for errors tagged at that call's final adapter boundary.
|
||||
*/
|
||||
export function isLlmAdapterFailure(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
value: unknown,
|
||||
): value is Error & { code?: string } {
|
||||
const failures = adapterFailureScopes.get(stream)
|
||||
return value instanceof Error && failures !== undefined && failures.failures.has(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve normalized provider facts only for an Error tagged by this exact
|
||||
* model call's final adapter boundary.
|
||||
* @param stream - the exact stream returned to the consumer.
|
||||
* @param value - the caught failure.
|
||||
* @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures.
|
||||
*/
|
||||
export function llmFailureOf(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
value: unknown,
|
||||
): LlmFailure | undefined {
|
||||
const failures = adapterFailureScopes.get(stream)
|
||||
return value instanceof Error ? failures?.failures.get(value) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the retry policy of the exact registration selected at this call's
|
||||
* final adapter boundary. The policy remains available after that registration
|
||||
* is disposed or replaced; absence means no final adapter served the call.
|
||||
* @param stream - the exact stream returned by the model call.
|
||||
* @returns the immutable serving-registration policy, or `undefined`.
|
||||
*/
|
||||
export function llmRetryPolicyOf(
|
||||
stream: AsyncIterable<StreamChunk>,
|
||||
): ResolvedRetryPolicy | undefined {
|
||||
return adapterFailureScopes.get(stream)?.retryPolicy
|
||||
}
|
||||
|
||||
@@ -127,11 +127,15 @@ export class BlockAssembler {
|
||||
|
||||
/**
|
||||
* Assemble all blocks seen so far, in stream order.
|
||||
* @returns one block per seen index; an open block assembles from its
|
||||
* accumulated deltas (an unknown block type never closed by `block-end` throws).
|
||||
* @returns one block per seen index, except that max-token truncation drops
|
||||
* tool calls that cannot be executed safely; an open block assembles from
|
||||
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
|
||||
*/
|
||||
blocks(): ContentBlock[] {
|
||||
return this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
const blocks = this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
return this.finish.kind === 'max-tokens'
|
||||
? blocks.filter(block => block.type !== 'tool-call')
|
||||
: blocks
|
||||
}
|
||||
|
||||
/** Usage from the `usage` chunk; undefined until one arrives. */
|
||||
|
||||
@@ -22,8 +22,7 @@ import type { ProviderRequestId } from './brand.ts'
|
||||
import { callConfigEquals, deepFreeze } from './call-config.ts'
|
||||
import type { LlmCallConfig } from './call-config.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts'
|
||||
import type { AdapterFailureScope } from './adapter-failure.ts'
|
||||
import { normalizeLlmFailure } from './adapter-failure.ts'
|
||||
|
||||
export * from './attribution.ts'
|
||||
export * from './brand.ts'
|
||||
@@ -35,7 +34,6 @@ export * from './retry-policy.ts'
|
||||
export { BlockAssembler } from './assembler.ts'
|
||||
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
|
||||
export type { LlmCallConfig } from './call-config.ts'
|
||||
export { isLlmAdapterFailure, llmFailureOf, llmRetryPolicyOf } from './adapter-failure.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -113,6 +111,8 @@ export class LlmError extends HarnessError {
|
||||
export interface PreparedLlmCall {
|
||||
/** Detached, deep-frozen config with any adapter-owned default materialized. */
|
||||
readonly config: LlmCallConfig
|
||||
/** Immutable retry policy captured with the adapter registration. */
|
||||
readonly retryPolicy: ResolvedRetryPolicy
|
||||
/**
|
||||
* Dispatch this call once through the registration captured during
|
||||
* preparation. The request's call-config fields must match {@link config};
|
||||
@@ -440,10 +440,17 @@ export class LlmService extends Service {
|
||||
let dispatched = false
|
||||
return Object.freeze({
|
||||
config: resolvedConfig,
|
||||
retryPolicy: registration.retryPolicy,
|
||||
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
|
||||
if (dispatched) {
|
||||
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
|
||||
}
|
||||
if (!callConfigEquals(options, resolvedConfig)) {
|
||||
throw new LlmError(
|
||||
'prepared LLM call config changed before adapter dispatch',
|
||||
'INVALID_PREPARED_CALL',
|
||||
)
|
||||
}
|
||||
dispatched = true
|
||||
return this.streamWithRegistration(options, { registration, config: resolvedConfig })
|
||||
},
|
||||
@@ -473,31 +480,20 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Final adapter boundary. It tags only failures from adapter selection,
|
||||
* synchronous dispatch, iterator construction, or iteration while preserving
|
||||
* the original Error object. Middleware outside this generator remains
|
||||
* distinguishable as plugin work. An iteration failure skips adapter cleanup
|
||||
* so it cannot suppress the primary provider error. A downstream close awaits
|
||||
* adapter cleanup, whose failures remain ordinary untagged work.
|
||||
* Final adapter boundary. Adapter selection, dispatch, iterator construction,
|
||||
* and iteration failures become one terminal failure chunk. Middleware and
|
||||
* downstream consumer failures remain thrown plugin or consumer errors.
|
||||
*/
|
||||
private async * adapterStream(
|
||||
options: GenerateOptions,
|
||||
failures: AdapterFailureScope,
|
||||
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
let iterator: AsyncIterator<StreamChunk>
|
||||
try {
|
||||
const registration = prepared?.registration ?? this.registration(options.provider)
|
||||
failures.retryPolicy = registration.retryPolicy
|
||||
const resolvedConfig = prepared === undefined
|
||||
? await this.resolveCallConfigFor(registration, options, options.signal)
|
||||
: prepared.config
|
||||
if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) {
|
||||
throw new LlmError(
|
||||
'prepared LLM call config changed before adapter dispatch',
|
||||
'INVALID_PREPARED_CALL',
|
||||
)
|
||||
}
|
||||
const resolvedOptions = prepared !== undefined || callConfigEquals(options, resolvedConfig)
|
||||
? options
|
||||
: Object.isFrozen(options)
|
||||
@@ -507,32 +503,31 @@ export class LlmService extends Service {
|
||||
const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter))
|
||||
iterator = stream[Symbol.asyncIterator]()
|
||||
} catch (error: unknown) {
|
||||
throw markLlmAdapterFailure(failures, error)
|
||||
yield adapterFailureChunk(error, options.signal)
|
||||
return
|
||||
}
|
||||
|
||||
let completed = false
|
||||
let iterationFailed = false
|
||||
try {
|
||||
while (true) {
|
||||
let value: StreamChunk
|
||||
let item: IteratorResult<StreamChunk>
|
||||
try {
|
||||
const item = await iterator.next()
|
||||
if (item.done) {
|
||||
completed = true
|
||||
return
|
||||
}
|
||||
value = item.value
|
||||
item = await iterator.next()
|
||||
} catch (error: unknown) {
|
||||
iterationFailed = true
|
||||
throw markLlmAdapterFailure(failures, error)
|
||||
completed = true
|
||||
yield adapterFailureChunk(error, options.signal)
|
||||
return
|
||||
}
|
||||
if (item.done) {
|
||||
completed = true
|
||||
return
|
||||
}
|
||||
// End the adapter-owned try before yielding: consumer/middleware
|
||||
// failures resumed into this generator must remain untagged.
|
||||
yield value
|
||||
// failures resumed into this generator must remain thrown.
|
||||
yield item.value
|
||||
}
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the iteration catch sets its latch before entering finally.
|
||||
if (!completed && !iterationFailed) {
|
||||
if (!completed) {
|
||||
const close = iterator.return?.bind(iterator)
|
||||
if (close) await close()
|
||||
}
|
||||
@@ -540,15 +535,13 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one model call as raw chunks (token-level deltas). Throws
|
||||
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
|
||||
* `options.provider`. Replay state is retained only when the same adapter
|
||||
* instance owns its historical provider and the target provider. Final
|
||||
* adapter selection remains fixed through asynchronous exact-model resolution
|
||||
* and dispatch. Selection, dispatch, and iteration failures retain their
|
||||
* original Error identity and are tagged in a call-local scope for narrow
|
||||
* agent-loop request recovery; middleware and nested-call failures remain
|
||||
* untagged for the outer call.
|
||||
* Stream one model call as raw chunks (token-level deltas). Replay state is
|
||||
* retained only when the same adapter instance owns its historical provider
|
||||
* and the target provider. Final adapter selection remains fixed through
|
||||
* asynchronous exact-model resolution and dispatch. Adapter selection,
|
||||
* dispatch, and iteration failures become terminal `error` or `aborted`
|
||||
* finish chunks; middleware, nested-call, cleanup, and consumer failures
|
||||
* remain thrown.
|
||||
* @param options - the full request; `options.provider` selects the adapter.
|
||||
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
|
||||
*/
|
||||
@@ -560,14 +553,23 @@ export class LlmService extends Service {
|
||||
options: GenerateOptions,
|
||||
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const failures: AdapterFailureScope = { failures: new WeakMap<Error, LlmFailure>() }
|
||||
const stream = this.ctx.waterfall(
|
||||
return this.ctx.waterfall(
|
||||
this,
|
||||
'llm/stream',
|
||||
options,
|
||||
() => this.adapterStream(options, failures, prepared),
|
||||
() => this.adapterStream(options, prepared),
|
||||
)
|
||||
return bindAdapterFailureScope(stream, failures)
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one adapter throw into the stream protocol's terminal outcome. */
|
||||
function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {
|
||||
const failure = normalizeLlmFailure(error)
|
||||
return {
|
||||
type: 'finish',
|
||||
reason: signal?.aborted || failure.code === 'ABORTED'
|
||||
? { kind: 'aborted', failure }
|
||||
: { kind: 'error', failure },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ async function* validateStream(
|
||||
usageSeen = true
|
||||
break
|
||||
case 'finish':
|
||||
if (open.size > 0) fail(`LLM stream finished with ${open.size} open block(s)`)
|
||||
if (open.size > 0 && chunk.reason.kind !== 'error' && chunk.reason.kind !== 'aborted') {
|
||||
fail(`LLM stream finished with ${open.size} open block(s)`)
|
||||
}
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -170,8 +170,9 @@ export interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
||||
* assembled block. Adapters emit usage before the terminal finish and nothing
|
||||
* afterward; tool arguments remain raw JSON strings. Failures either throw or
|
||||
* end with `error`/`aborted`, and consumers must handle both paths.
|
||||
* afterward; tool arguments remain raw JSON strings. An adapter implementation
|
||||
* may throw, but `LlmService.stream()` normalizes that failure to a terminal
|
||||
* `error` or `aborted` finish before exposing it to consumers.
|
||||
*/
|
||||
export type StreamChunk =
|
||||
| { type: 'block-start'; index: number; blockType: ContentBlockType }
|
||||
|
||||
@@ -670,7 +670,7 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
}] })
|
||||
activeMeter.measure(session)
|
||||
session.append('user/message', createUserMessage({
|
||||
|
||||
@@ -19,7 +19,7 @@ function event(active: unknown): SessionEvent {
|
||||
function emitTurnStart(ctx: Context, session: Session): void {
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('plan-mode stream invariants', () => {
|
||||
expect(() => {
|
||||
ctx.emit('tools/change')
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
type: 'turn/start', seq: 0, time: 0, data: { turn: 1 },
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
@@ -65,7 +65,7 @@ describe('plan-mode stream invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
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('plan/mode', { active: 'plan' as unknown as boolean })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
@@ -77,7 +77,7 @@ describe('plan-mode stream invariants', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
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('plan/mode', { active: true })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
@@ -75,7 +75,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type:
|
||||
|
||||
/** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */
|
||||
function openTurn(session: Session, turn = 0): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
}
|
||||
|
||||
/** Close the open turn (the between-turns shape: selections commit immediately). */
|
||||
|
||||
@@ -58,7 +58,7 @@ function runPlanCommand(session: Session, args: string, index: number): void {
|
||||
|
||||
/** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */
|
||||
function commitPlanMode(session: Session, active: boolean, turn: number): void {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('plan/mode', { active })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function agent(ctx: Context): Agent {
|
||||
const id = SessionId('agent')
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ describe('pty-local plugin shape', () => {
|
||||
|
||||
const session = ctx.sessions.create(SessionId('unowned-mode'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
}).not.toThrow()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
|
||||
})
|
||||
@@ -249,7 +249,7 @@ describe('pty-local plugin shape', () => {
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
@@ -258,7 +258,7 @@ describe('pty-local plugin shape', () => {
|
||||
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
|
||||
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
|
||||
@@ -292,7 +292,7 @@ describe('pty-local plugin shape', () => {
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
send: () => {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
|
||||
const id = SessionId('pty-loader-agent')
|
||||
const value: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const agent: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('session-checkpoint-policy request boundary', () => {
|
||||
it('awaits the live session checkpoint before constructing the downstream model stream', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('request-checkpoint'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
function appendClosedTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -208,7 +208,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
|
||||
const m = meta('chunks')
|
||||
const log: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
|
||||
{ type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
|
||||
@@ -342,7 +342,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
|
||||
await writeFile(path, [
|
||||
JSON.stringify(toHeaderLine(m)),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
'',
|
||||
@@ -397,7 +397,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
// partial line with no newline (a torn fragment never fully flushed).
|
||||
const path = rawLogPath(root, '/proj', m.id)
|
||||
await writeFile(path, [
|
||||
JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
|
||||
'{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
|
||||
].join('\n'), { flag: 'a' })
|
||||
@@ -416,7 +416,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
|
||||
// The next append continues at seq 10 (the balanced length).
|
||||
const turn3 = [
|
||||
{ type: 'turn/start', seq: 10, time: 11, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 10, time: 11, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
await ctx.sessionPersistence.append(m.id, turn3)
|
||||
@@ -435,7 +435,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
|
||||
await ctx.sessionPersistence.load(m.id)
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
|
||||
@@ -463,7 +463,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
const turn2 = [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
// The append rejects, but the partial bytes are truncated back: the file is
|
||||
@@ -501,7 +501,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
|
||||
try {
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
] as SessionEvent[])
|
||||
throw new Error('expected append to reject')
|
||||
} catch (error) {
|
||||
@@ -526,7 +526,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
// metadata must be unaffected, so a later append still finds the right log.
|
||||
mutableHeader(loaded.meta).cwd = '/evil'
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
// The append landed in the ORIGINAL /proj log, not beside an /evil path.
|
||||
@@ -543,7 +543,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 1 },
|
||||
}])
|
||||
await ctx.sessionPersistence.create(b)
|
||||
await ctx.sessionPersistence.append(b.id, oneTurnLog())
|
||||
@@ -596,8 +596,8 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
|
||||
|
||||
const a = ctx.sessions.create(SessionId('sa'))
|
||||
const b = ctx.sessions.create(SessionId('sb'))
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
a.append('turn/start', { turn: 1 })
|
||||
b.append('turn/start', { turn: 1 })
|
||||
a.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'A' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -678,7 +678,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
|
||||
].join('\n') + '\n'
|
||||
// No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the
|
||||
@@ -690,7 +690,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
|
||||
JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
@@ -719,7 +719,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
it('a corrupt line after the last turn/end bounds the preserved tail', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
'{not json', // corrupt crash fragment, no turn/end committed
|
||||
].join('\n') + '\n'
|
||||
// The contiguous prefix (turn/start seq 0) is preserved; the corrupt
|
||||
@@ -730,7 +730,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
|
||||
const log = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
|
||||
].join('\n') + '\n'
|
||||
@@ -760,7 +760,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } },
|
||||
}))
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
...deltas,
|
||||
{ type: 'assistant/message', seq: 7, time: 8, data: {
|
||||
@@ -851,7 +851,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
|
||||
it('scanLog: a packed row advances the seq cursor by its whole run', () => {
|
||||
const logText = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
@@ -873,7 +873,7 @@ describe('SessionPersistenceJsonl: default packed chunk rows', () => {
|
||||
it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => {
|
||||
const logText = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
|
||||
// seq0 skips 1 — the run's first member is already a gap; no turn/end follows.
|
||||
JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
|
||||
].join('\n') + '\n'
|
||||
@@ -1151,7 +1151,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
// A live session materializes and owns the id.
|
||||
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
|
||||
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
a.append('turn/start', { turn: 1 })
|
||||
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
|
||||
@@ -1229,7 +1229,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await ctx2.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[])
|
||||
const loaded = await ctx2.sessionPersistence.load(m.id)
|
||||
@@ -1244,7 +1244,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
const m = meta('open-turn', '/h')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
] as SessionEvent[])
|
||||
const { events } = await ctx.sessionPersistence.load(m.id)
|
||||
expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end'])
|
||||
@@ -1276,7 +1276,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
const session = ctx2.sessions.create(SessionId('flush-fail'))
|
||||
// A full turn lands in the write-behind buffer.
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
const path = logPath(root, header.cwd, header.id, 'zstd')
|
||||
const before = await readFile(path)
|
||||
const secondTurn = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
await ctx.sessionPersistence.append(header.id, secondTurn)
|
||||
@@ -380,7 +380,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
const path = logPath(root, header.cwd, header.id, 'zstd')
|
||||
const committed = await readFile(path)
|
||||
const openTurn = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } },
|
||||
] as SessionEvent[]
|
||||
@@ -427,7 +427,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
await ctx.sessionPersistence.append(header.id, oneTurnLog())
|
||||
const path = logPath(root, header.cwd, header.id, 'zstd')
|
||||
const secondTurn = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const frame = await compressZstdFrame(secondTurn.map(e => JSON.stringify(e)).join('\n') + '\n')
|
||||
@@ -475,7 +475,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
return realSync.call(this)
|
||||
})
|
||||
const secondTurn = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/)
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('scanRows', () => {
|
||||
// is no torn fragment to delete. (load() then synthesizes the closers.)
|
||||
const withOpenTurn: SessionEvent[] = [
|
||||
...oneTurnLog(),
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
]
|
||||
const { preserved, tornFrom } = scanRows(rows(withOpenTurn))
|
||||
@@ -119,7 +119,7 @@ describe('scanRows', () => {
|
||||
// A gap after seq 0 (no committed turn/end): seq 0 is the preserved
|
||||
// interrupted-turn event; the gap bounds it and marks the torn fragment.
|
||||
const gapped: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
|
||||
]
|
||||
const { preserved, tornFrom } = scanRows(rows(gapped))
|
||||
@@ -133,7 +133,7 @@ describe('scanRows', () => {
|
||||
|
||||
it('throws on a seq gap inside the committed region (before the last turn/end)', () => {
|
||||
const gapped: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
|
||||
{ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
@@ -183,7 +183,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
|
||||
.run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta')
|
||||
const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
||||
insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 }))
|
||||
insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
|
||||
insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
|
||||
db.close()
|
||||
@@ -228,7 +228,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await ctx1.sessionPersistence.create(m)
|
||||
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
|
||||
await ctx1.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
])
|
||||
await fiber1.dispose()
|
||||
@@ -251,7 +251,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
// load durably closed the turn, so the next append continues at the balanced
|
||||
// length (seq 10) and a reload round-trips identically.
|
||||
await ctx2.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await ctx2.sessionPersistence.load(m.id)
|
||||
@@ -269,7 +269,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
// Hand-write an interrupted turn (turn/start seq 6, no turn/end).
|
||||
const db = openDatabase(path, 'wal')
|
||||
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
|
||||
.run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
||||
.run(m.id, 'turn/start', JSON.stringify({ turn: 2 }))
|
||||
db.close()
|
||||
|
||||
const b2 = await backend(path)
|
||||
@@ -294,7 +294,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await b1.ctx.sessionPersistence.create(m)
|
||||
// A first turn that NEVER completed: turn/start + user/message, no turn/end.
|
||||
await b1.ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}) },
|
||||
@@ -477,7 +477,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers)
|
||||
// load physically deleted the corrupt tail row, so a fresh append continues.
|
||||
await b2.ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await b2.ctx.sessionPersistence.load(m.id)
|
||||
@@ -703,7 +703,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
const b2 = await backend(path)
|
||||
await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
|
||||
const turn2: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
// b1 commits seq 6..7 first.
|
||||
@@ -761,7 +761,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create(SessionId('hmr-collide'))
|
||||
}, { inject: ['sessions'] }))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await ctx.plugin(SessionPersistenceSqlite, { path })
|
||||
await expectFlushError(ctx.sessions.flush(session), /id collision/)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -814,7 +814,7 @@ describe('surface field round-trip', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
||||
const session = ctx.sessions.create(SessionId('roundtrip-surface'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
@@ -849,7 +849,7 @@ describe('surface field round-trip', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
||||
const session = ctx.sessions.create(SessionId('surface-noseq'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('steering/message', {
|
||||
turn: 1,
|
||||
message: createUserMessage({
|
||||
|
||||
@@ -33,7 +33,7 @@ export function meta(id: string, cwd?: string): SessionHeader {
|
||||
/** A well-formed one-turn event log (contiguous seqs from 0). */
|
||||
export function oneTurnLog(): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: 1, time: 2, data: freezeMessage({
|
||||
id: MessageId('one-turn-user'),
|
||||
role: 'user',
|
||||
@@ -124,7 +124,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
// A second turn that crashed mid-flight: turn/start + step/start were
|
||||
// durably written, but no step/end / turn/end ever arrived.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
])
|
||||
const beforeRepair = (await persistence.listSnapshots())
|
||||
@@ -157,7 +157,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
// The closed log is durable and continuable: a fresh append continues at
|
||||
// the balanced length (seq 10), and a reload round-trips identically.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
|
||||
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
||||
])
|
||||
const reloaded = await persistence.load(m.id)
|
||||
@@ -177,7 +177,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
// BEFORE the tool/result was written (the loop runs tools after logging
|
||||
// the assistant message — a process killed mid-tool lands exactly here).
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 8, time: 9, data: {
|
||||
turn: 2, step: 1,
|
||||
@@ -227,7 +227,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
const m = meta('unknown-tool-outcome')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 3, data: {
|
||||
turn: 1, step: 1,
|
||||
@@ -318,7 +318,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
|
||||
// Non-mutating: an interrupted-turn log is served as stored, no closers.
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
|
||||
])
|
||||
const tail = await persistence.readFrom(m.id, 6)
|
||||
expect(tail.events.map(event => event.type)).toEqual(['turn/start'])
|
||||
@@ -347,7 +347,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
type: 'turn/start',
|
||||
seq: 6,
|
||||
time: 7,
|
||||
data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
data: { turn: 2 },
|
||||
}])
|
||||
const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
|
||||
expect(changed?.revision).not.toBe(first?.revision)
|
||||
@@ -376,7 +376,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
const m = meta('s4')
|
||||
await persistence.create(m)
|
||||
const gapped: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1
|
||||
]
|
||||
await expect(persistence.append(m.id, gapped)).rejects.toThrow()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user