Merge master into codex/simp-session-log-representation
This commit is contained in:
@@ -15,4 +15,4 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
|
||||
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentId, AgentOptions, AgentStatus, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
@@ -225,14 +225,20 @@ export class ReactLoopAgent implements Agent {
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void {
|
||||
this.assertNotDisposed()
|
||||
const source = this.resolveSource(options)
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
|
||||
...options?.meta !== undefined ? { meta: options.meta } : {},
|
||||
}
|
||||
if (isTurnOpen(this.session)) {
|
||||
// A turn is open in the LOG (decided from the log, not agent status —
|
||||
// status can be `running` with no turn open): the context/message is
|
||||
// turn-enclosed by that turn, so append it directly.
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
this.session.append('context/message', context, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
@@ -244,7 +250,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// are contained by Session and cannot create a false append failure.
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
this.session.append('context/message', context, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. A pre-commit veto
|
||||
// must escape rather than being mistaken for a committed turn/end.
|
||||
|
||||
@@ -234,10 +234,15 @@ async function runTurn(
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = decision.content ?? message.content
|
||||
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
|
||||
// `allow.additionalContext` is a SEPARATE context/message the next request
|
||||
// also sees. The turn is open, so inject() appends it into THIS turn.
|
||||
if (decision.additionalContext) {
|
||||
agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source })
|
||||
// Every `allow.additionalContexts` entry is a separate context/message the
|
||||
// next request also sees. The turn is open, so inject() appends each one
|
||||
// into THIS turn without flattening provenance, framing, or metadata.
|
||||
for (const context of decision.additionalContexts ?? []) {
|
||||
agent.inject(context.content, {
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,7 +592,7 @@ async function runStep(
|
||||
// Persist tool-owned presentation data for replay.
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
if (result.additionalContext) pendingContext.push(result.additionalContext)
|
||||
pendingContext.push(...result.additionalContexts ?? [])
|
||||
// The signal may flip while the tool is awaited.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
@@ -597,7 +602,11 @@ async function runStep(
|
||||
|
||||
// Append buffered context after the complete result batch.
|
||||
for (const context of pendingContext) {
|
||||
agent.inject(context.content, { source: context.source })
|
||||
agent.inject(context.content, {
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
})
|
||||
}
|
||||
|
||||
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
|
||||
|
||||
@@ -17,7 +17,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
|
||||
* `agent/session-start`, the reshaped `agent/turn-continuation`
|
||||
* ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
|
||||
* split with `additionalContext` buffering. These verify the canonical event
|
||||
* split with `additionalContexts` buffering. These verify the canonical event
|
||||
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
|
||||
* external protocol — a native plugin uses the typed decisions directly.
|
||||
*/
|
||||
@@ -91,15 +91,21 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
|
||||
})
|
||||
|
||||
it('allow with additionalContext injects a separate context/message into the turn', async () => {
|
||||
it('allow with additionalContexts injects separate context/message events into the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const meta = { kind: 'prompt-context', version: 1 }
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}],
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -109,14 +115,16 @@ describe('agent/prompt-submit', () => {
|
||||
const userMsg = log.find(e => e.type === 'user/message')
|
||||
const ctxMsg = log.find(e => e.type === 'context/message')
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
// both the prompt and the injected context reach the model
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
|
||||
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
|
||||
it('a prompt-submit rewrite + additionalContexts is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
|
||||
// Prompt rewrites and injected context land before `agent/pre-step`, so a
|
||||
// compaction listener measures the current surface before the single derive.
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
@@ -127,7 +135,7 @@ describe('agent/prompt-submit', () => {
|
||||
({
|
||||
kind: 'allow',
|
||||
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
|
||||
additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
additionalContexts: [{ content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }],
|
||||
}))
|
||||
|
||||
// The pre-step seam (where compaction lives) derives the surface it would act
|
||||
@@ -538,8 +546,8 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
|
||||
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
|
||||
describe('tool additionalContexts buffering across a step', () => {
|
||||
it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => {
|
||||
// One assistant step with TWO tool calls; the second model response stops.
|
||||
const twoCalls = [
|
||||
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
|
||||
@@ -557,9 +565,17 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// Each call attaches additionalContext naming itself.
|
||||
// Each call attaches one context naming itself.
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
|
||||
({
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
|
||||
source: { kind: 'plugin', plugin: 'p' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -579,6 +595,37 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste
|
||||
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const contextEvents = events(agent).filter(e => e.type === 'context/message')
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
})
|
||||
|
||||
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'composite', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'composite', description: 'composite', parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
|
||||
return [{ type: 'text', text: 'outer result' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const resultIndex = log.findIndex(event => event.type === 'tool/result')
|
||||
const contextEvents = log.filter(event => event.type === 'context/message')
|
||||
expect(resultIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'a' },
|
||||
{ kind: 'plugin', plugin: 'b' },
|
||||
])
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -638,7 +685,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') {
|
||||
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
|
||||
return { kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } }] }
|
||||
}
|
||||
return decision
|
||||
})
|
||||
|
||||
@@ -383,6 +383,32 @@ describe('agent loop', () => {
|
||||
expect(flat).toContain('<context source=\\"plugin\\">')
|
||||
})
|
||||
|
||||
it('inject() can persist raw structured context without the generic context envelope', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('raw-context'), { model: 'mock' })
|
||||
const text = '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>'
|
||||
const meta = {
|
||||
kind: 'workspace-instructions',
|
||||
version: 1,
|
||||
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
|
||||
}
|
||||
|
||||
agent.inject([{ type: 'text', text }], {
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ envelope: 'raw', meta })
|
||||
const requestText = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(requestText).not.toContain('<context source=')
|
||||
})
|
||||
|
||||
it('inject() while running appends into the open turn (no extra synthetic turn)', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'noticer', {}, 'calling'),
|
||||
|
||||
@@ -33,6 +33,8 @@ The loop plugin registers `AgentFactory`, keeping consumers independent of its c
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
|
||||
|
||||
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
### Agent interface (`types.ts`)
|
||||
@@ -41,7 +43,7 @@ The handle every plugin programs against:
|
||||
|
||||
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
|
||||
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
|
||||
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
|
||||
- `agent.inject(content, options?)` — inject in-session context (`context/message` event); the next request sees it. `options.envelope` defaults to the canonical `<context>` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
|
||||
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
|
||||
- `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`
|
||||
|
||||
@@ -22,7 +22,7 @@ export type AgentId = Branded<'AgentId'>
|
||||
export function AgentId(id: string): AgentId {
|
||||
return id as AgentId
|
||||
}
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { ContextEnvelope, JsonValue, Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -42,6 +42,14 @@ export interface SendOptions {
|
||||
source?: MessageSource
|
||||
}
|
||||
|
||||
/** Options specific to durable synthetic context injection. */
|
||||
export interface InjectOptions extends SendOptions {
|
||||
/** Keep the canonical context tag, or send caller-owned framing verbatim. */
|
||||
envelope?: ContextEnvelope
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
|
||||
@@ -54,21 +62,25 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
/** Keep the canonical context tag, or use caller-owned framing verbatim. */
|
||||
envelope?: ContextEnvelope
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt and
|
||||
* `additionalContext` becomes a separate context message. `block` records a
|
||||
* Prompt interception result. `allow.content` replaces the prompt and each
|
||||
* `additionalContexts` entry becomes a separate context message. `block` records a
|
||||
* durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
| { kind: 'block'; reason: string }
|
||||
|
||||
/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
|
||||
export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
| { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
|
||||
|
||||
/**
|
||||
* The terminal subset of {@link ContinuationDecision}. A listener on
|
||||
@@ -108,7 +120,7 @@ export interface Agent {
|
||||
* turn joins it at the current log position. Disposal awaits idle checkpoints;
|
||||
* flush failures are reported through `agent/error`, not thrown to the caller.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: SendOptions): void
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void
|
||||
|
||||
/**
|
||||
* Clear queued and steering work, including work waiting to start, and abort
|
||||
|
||||
@@ -32,7 +32,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
|
||||
- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite.
|
||||
@@ -48,13 +48,15 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
|
||||
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
|
||||
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
|
||||
- `foldSurface(events)` — replay the canonical surface transitions into detached current event sequences and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining only its incremental sequence cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second is the type-only check used to detect a surface-eligible event missing its marker when validating a seed or loaded log.
|
||||
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
|
||||
@@ -13,9 +13,9 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
@@ -131,43 +131,6 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
|
||||
function assertSurfaceMetadataShape(
|
||||
type: string,
|
||||
surfaceOp: unknown,
|
||||
sourceEventSeqs: unknown,
|
||||
): void {
|
||||
const eligible = isSurfaceEligibleType(type)
|
||||
if (!eligible) {
|
||||
if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
|
||||
throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
if (surfaceOp !== 'append') {
|
||||
if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
|
||||
throw new Error(`session event "${type}" carries an invalid surfaceOp`)
|
||||
}
|
||||
const op = surfaceOp as Record<string, unknown>
|
||||
const keys = Object.keys(op)
|
||||
if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
|
||||
|| op['op'] !== 'replace'
|
||||
|| typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
|
||||
|| typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
|
||||
throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
|
||||
}
|
||||
}
|
||||
if (sourceEventSeqs !== undefined) {
|
||||
if (!Array.isArray(sourceEventSeqs)
|
||||
|| sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
|
||||
throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
|
||||
const event = value
|
||||
@@ -241,6 +204,22 @@ interface SessionEntry {
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* Render one context contribution exactly as it will appear in model history.
|
||||
* @param content - content blocks supplied by the context producer.
|
||||
* @param source - attribution used by the canonical context envelope.
|
||||
* @param envelope - canonical tagged framing or caller-owned raw framing.
|
||||
* @returns a detached block list ready for the derived model transcript.
|
||||
*/
|
||||
export function renderContextContent(
|
||||
content: ContentBlock[],
|
||||
source: MessageSource,
|
||||
envelope: ContextEnvelope = 'context',
|
||||
): ContentBlock[] {
|
||||
const cloned = structuredClone(content)
|
||||
return envelope === 'raw' ? cloned : renderTagged('context', cloned, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
@@ -249,13 +228,15 @@ const attachments = new WeakMap<Session, SessionEntry>()
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
/** Incremental acceptance state, kept separate from the public lazy view. */
|
||||
private readonly surfaceValidator = new SurfaceManager(this.log)
|
||||
|
||||
/**
|
||||
* Derived surface — a cached order of message-producing event sequences.
|
||||
* Lazily rebuilt from `surfaceOp` markers in the log; processes only new
|
||||
* events (delta) on each access — the log is append-only, so prior events
|
||||
* never change.
|
||||
* `append`. Undefined until first accessed (including after fork/seed).
|
||||
* Undefined until first accessed (including after fork/seed).
|
||||
*/
|
||||
private _surface: SurfaceManager | undefined
|
||||
|
||||
@@ -284,7 +265,7 @@ export class Session {
|
||||
// `seq = log.length` contract the whole system relies on). Without this,
|
||||
// a bad seed would surface only later as a backend rejection or a silent
|
||||
// divergence between the live log and disk.
|
||||
this.log = Array.from(seed, (source, index) => {
|
||||
for (const [index, source] of seed.entries()) {
|
||||
// The seed is a persistence/replay boundary: validate and detach the
|
||||
// complete event in one lossless-JSON pass.
|
||||
const snapshot = snapshotJsonValue(source)
|
||||
@@ -296,20 +277,16 @@ export class Session {
|
||||
if (snapshot.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
}
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
|
||||
// the sole source of derived history, so a marker-less message event
|
||||
// would load fine yet vanish from deriveMessages(). `append` enforces
|
||||
// this at compile time via its typed overload; a seed arrives as raw
|
||||
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
|
||||
// runtime here rather than silently resuming with empty history.
|
||||
const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
|
||||
// A seed is accepted incrementally through the same transition as a
|
||||
// live append and a full-log fold. The candidate is planned before it
|
||||
// enters `log`, so a failure cannot partially mutate the surface.
|
||||
try {
|
||||
assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
|
||||
this.surfaceValidator.validateNext(snapshot)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
|
||||
}
|
||||
return deepFreeze(snapshot)
|
||||
})
|
||||
this.log.push(deepFreeze(snapshot))
|
||||
}
|
||||
}
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
}
|
||||
@@ -356,7 +333,10 @@ export class Session {
|
||||
* @throws if `data` or surface metadata is not losslessly JSON-serializable
|
||||
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
|
||||
* circular reference, sparse array, or an exotic object such as
|
||||
* Map/Set/Date/class instance). One recursive pass reads, validates, and
|
||||
* Map/Set/Date/class instance), or when the candidate violates the
|
||||
* canonical surface contract (marker shape and eligibility, unique
|
||||
* earlier provenance, positional replacement validity, and complete
|
||||
* shadowed-node coverage). One recursive pass reads, validates, and
|
||||
* copies each nested value once, so a stateful getter cannot supply one value
|
||||
* to validation and another to storage. The event log is the durable source
|
||||
* of truth, so a bad event fails at the append site rather than later during
|
||||
@@ -383,25 +363,21 @@ export class Session {
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
|
||||
const entry = attachments.get(this)
|
||||
if (entry?.appending) {
|
||||
throw new Error('session append cannot reenter while another append is being published')
|
||||
}
|
||||
const event = deepFreeze({
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
|
||||
} as unknown as SessionEvent<T>)
|
||||
this.surfaceValidator.validateNext(event as SessionEvent)
|
||||
|
||||
if (entry !== undefined) entry.appending = true
|
||||
try {
|
||||
const event = deepFreeze({
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>)
|
||||
let callbacks: SessionCallback[] | undefined
|
||||
const callbackArgs: unknown[] = [this, event]
|
||||
if (entry !== undefined) {
|
||||
@@ -531,8 +507,8 @@ export class Session {
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('context', content, source) }
|
||||
const { content, source, envelope } = event.data
|
||||
return { role: 'user', content: renderContextContent(content, source, envelope) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
|
||||
@@ -61,40 +61,106 @@ interface SurfaceFoldState {
|
||||
replaceGeneration: number
|
||||
}
|
||||
|
||||
/** Create one empty fold state. */
|
||||
/** A validated replacement transition that has not mutated fold state yet. */
|
||||
interface SurfaceReplacePlan extends SurfaceFoldReplacement {
|
||||
kind: 'replace'
|
||||
startIdx: number
|
||||
endIdx: number
|
||||
}
|
||||
|
||||
/** One validated surface transition that has not mutated fold state yet. */
|
||||
type SurfacePlan =
|
||||
| { kind: 'append'; seq: number }
|
||||
| SurfaceReplacePlan
|
||||
|
||||
/** Create an empty surface fold state. */
|
||||
function createFoldState(): SurfaceFoldState {
|
||||
return { nodes: [], replaceGeneration: 0 }
|
||||
}
|
||||
|
||||
/** Apply one event and return replacement metadata when one occurred. */
|
||||
function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
if (!isSurfaceEligibleType(event.type)) return
|
||||
if (!isSurfaceEvent(event)) {
|
||||
throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`)
|
||||
}
|
||||
if (event.surfaceOp === 'append') {
|
||||
state.nodes.push(event.seq)
|
||||
/** Whether a runtime value is a non-negative safe event sequence. */
|
||||
function isEventSeq(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
/** Whether a runtime value is the exact positional-replacement shape. */
|
||||
function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace' }> {
|
||||
const op = value as Record<string, unknown>
|
||||
return Object.keys(op).length === 3
|
||||
&& Object.hasOwn(op, 'op')
|
||||
&& Object.hasOwn(op, 'start')
|
||||
&& Object.hasOwn(op, 'end')
|
||||
&& op['op'] === 'replace'
|
||||
&& isEventSeq(op['start'])
|
||||
&& isEventSeq(op['end'])
|
||||
}
|
||||
|
||||
/** Validate event-local surface eligibility and return its operation. */
|
||||
function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
|
||||
const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
|
||||
if (!isSurfaceEligibleType(event.type)) {
|
||||
if (raw.surfaceOp !== undefined) {
|
||||
throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
|
||||
}
|
||||
if (raw.sourceEventSeqs !== undefined) {
|
||||
throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const op = raw.surfaceOp
|
||||
if (op === undefined) {
|
||||
throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
if (op === 'append') return op
|
||||
if (op === null || typeof op !== 'object' || Array.isArray(op)) {
|
||||
throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
|
||||
}
|
||||
if (!isReplaceOp(op)) {
|
||||
throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
const shadowedSeqs = replaceSurface(state, event.seq, event.surfaceOp)
|
||||
return {
|
||||
seq: event.seq,
|
||||
start: event.surfaceOp.start,
|
||||
end: event.surfaceOp.end,
|
||||
shadowedSeqs,
|
||||
/** Validate provenance against prior log entries and the replacement range. */
|
||||
function assertProvenance(
|
||||
event: SessionEvent,
|
||||
shadowedSeqs: readonly number[],
|
||||
): void {
|
||||
const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
|
||||
const sources = new Set<number>()
|
||||
if (raw !== undefined) {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
|
||||
}
|
||||
if (raw.length === 0) {
|
||||
throw new Error('sourceEventSeqs must not be empty when present')
|
||||
}
|
||||
let nonEarlierSource: number | undefined
|
||||
for (const source of raw) {
|
||||
if (!isEventSeq(source)) {
|
||||
throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
|
||||
}
|
||||
sources.add(source)
|
||||
if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
|
||||
}
|
||||
if (sources.size !== raw.length) {
|
||||
throw new Error('sourceEventSeqs must not contain duplicates')
|
||||
}
|
||||
if (nonEarlierSource !== undefined) {
|
||||
throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
|
||||
}
|
||||
}
|
||||
const missing = shadowedSeqs.filter(seq => !sources.has(seq))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace one inclusive surface range and return the removed sequences. */
|
||||
function replaceSurface(
|
||||
/** Locate one replacement range without mutating the current fold state. */
|
||||
function replacementRange(
|
||||
state: SurfaceFoldState,
|
||||
newSeq: number,
|
||||
op: Extract<SurfaceOp, { op: 'replace' }>,
|
||||
): number[] {
|
||||
): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
|
||||
const startIdx = state.nodes.indexOf(op.start)
|
||||
if (startIdx === -1) {
|
||||
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
|
||||
@@ -106,29 +172,78 @@ function replaceSurface(
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
|
||||
}
|
||||
return {
|
||||
startIdx,
|
||||
endIdx,
|
||||
shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const shadowedSeqs = state.nodes.splice(startIdx, endIdx - startIdx + 1, newSeq)
|
||||
state.replaceGeneration += 1
|
||||
return shadowedSeqs
|
||||
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
||||
function planSurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
): SurfacePlan | undefined {
|
||||
if (event.seq !== expectedSeq) {
|
||||
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
|
||||
}
|
||||
const surfaceOp = surfaceOpOf(event)
|
||||
if (surfaceOp === undefined) return
|
||||
if (surfaceOp === 'append') {
|
||||
assertProvenance(event, [])
|
||||
return { kind: 'append', seq: event.seq }
|
||||
}
|
||||
const range = replacementRange(state, surfaceOp)
|
||||
assertProvenance(event, range.shadowedSeqs)
|
||||
return {
|
||||
kind: 'replace',
|
||||
seq: event.seq,
|
||||
start: surfaceOp.start,
|
||||
end: surfaceOp.end,
|
||||
...range,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one event and return replacement metadata only when one occurred. */
|
||||
function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq)
|
||||
if (plan?.kind === 'append') {
|
||||
state.nodes.push(plan.seq)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
|
||||
state.replaceGeneration += 1
|
||||
}
|
||||
if (plan?.kind !== 'replace') return
|
||||
return {
|
||||
seq: plan.seq,
|
||||
start: plan.start,
|
||||
end: plan.end,
|
||||
shadowedSeqs: plan.shadowedSeqs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a complete event log through the canonical surface fold.
|
||||
* @param events - events in contiguous seq order.
|
||||
* Replay a complete session log through the canonical surface fold.
|
||||
* @param events - session events in contiguous seq order.
|
||||
* @returns detached current sequences and replacement history.
|
||||
* @throws when a surface marker is missing or names an invalid range.
|
||||
* @throws when an event violates surface metadata, provenance, or range rules.
|
||||
*/
|
||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const event of events) {
|
||||
const replacement = applySurfaceEvent(state, event)
|
||||
for (const [index, event] of events.entries()) {
|
||||
const replacement = applySurfaceEvent(state, event, index)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return { nodes: [...state.nodes], replacements }
|
||||
}
|
||||
|
||||
/** Incremental ordered surface view over an append-only session log. */
|
||||
/** Incremental ordered surface view and append-boundary validator. */
|
||||
export class SurfaceManager {
|
||||
/** Shared transition state; replacement history is not retained. */
|
||||
private _state = createFoldState()
|
||||
@@ -137,6 +252,15 @@ export class SurfaceManager {
|
||||
|
||||
constructor(private log: readonly SessionEvent[]) {}
|
||||
|
||||
/**
|
||||
* Validate the next candidate without mutating the committed surface.
|
||||
* @param event - candidate event that has not entered the log yet.
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(this._state, event, this.log.length)
|
||||
}
|
||||
|
||||
/** Monotonic count of folded positional replacements. */
|
||||
get replaceGeneration(): number {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
@@ -153,8 +277,8 @@ export class SurfaceManager {
|
||||
private _processDelta(): void {
|
||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[i]!)
|
||||
applySurfaceEvent(this._state, this.log[i]!, i)
|
||||
this._lastProcessedSeq = i
|
||||
}
|
||||
this._lastProcessedSeq = this.log.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from './json.ts'
|
||||
|
||||
/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */
|
||||
export type ContextEnvelope = 'context' | 'raw'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
@@ -201,9 +205,16 @@ export interface SessionEventMap {
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as tagged synthetic context — NOT a user prompt.
|
||||
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
|
||||
* own the complete model-facing frame; `meta` is durable JSON state omitted
|
||||
* from the model projection.
|
||||
*/
|
||||
'context/message': { content: ContentBlock[]; source: MessageSource }
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
envelope?: ContextEnvelope
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
||||
/**
|
||||
|
||||
@@ -59,6 +59,28 @@ describe('Session', () => {
|
||||
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
||||
})
|
||||
|
||||
it('renders raw context without a generic envelope while preserving structured metadata', () => {
|
||||
const session = new Session(SessionId('s2-raw'))
|
||||
const meta = {
|
||||
kind: 'workspace-instructions',
|
||||
version: 1,
|
||||
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
|
||||
}
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
expect(session.deriveMessages()).toEqual([{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
}])
|
||||
const event = session.events[0]
|
||||
expect(event?.type === 'context/message' && event.data.meta).toEqual(meta)
|
||||
})
|
||||
|
||||
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' } } })
|
||||
@@ -295,35 +317,52 @@ describe('Session', () => {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
}, {
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp,
|
||||
sourceEventSeqs: [0],
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-unstable-metadata'), seed)
|
||||
const event = session.events[0]!
|
||||
const event = session.events[1]!
|
||||
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
})
|
||||
|
||||
it('adds seed context when surface validation throws a non-Error value', () => {
|
||||
it.each([
|
||||
['an Error', new Error('validator failed'), 'validator failed'],
|
||||
['a non-Error value', 'validator failed', 'invalid surface metadata'],
|
||||
] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => {
|
||||
const originalHasOwn = Object.hasOwn
|
||||
const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
|
||||
if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
|
||||
if ((object as Record<string, unknown>)['op'] === 'replace') throw failure
|
||||
return originalHasOwn(object, property)
|
||||
})
|
||||
const seed = [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
}, {
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
try {
|
||||
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
|
||||
.toThrow('invalid seed event at index 0: invalid surface metadata')
|
||||
.toThrow(`invalid seed event at index 1: ${expected}`)
|
||||
} finally {
|
||||
hasOwn.mockRestore()
|
||||
}
|
||||
@@ -409,6 +448,11 @@ describe('Session', () => {
|
||||
|
||||
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-unstable-metadata'))
|
||||
const source = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
let reads = 0
|
||||
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
|
||||
enumerable: true,
|
||||
@@ -421,12 +465,12 @@ describe('Session', () => {
|
||||
const event = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
{ surfaceOp } as never,
|
||||
{ surfaceOp, sourceEventSeqs: [0] } as never,
|
||||
)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
expect(session.events).toEqual([event])
|
||||
expect(session.events).toEqual([source, event])
|
||||
})
|
||||
|
||||
it('rejects invalid plain surface metadata shapes at append', () => {
|
||||
@@ -462,7 +506,7 @@ describe('Session', () => {
|
||||
'turn/start',
|
||||
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
{ surfaceOp: 'append' },
|
||||
)).toThrow(/not surface-eligible and cannot carry surface metadata/)
|
||||
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
Session,
|
||||
SessionId,
|
||||
foldSurface,
|
||||
isSurfaceEligibleType,
|
||||
isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
@@ -13,6 +19,64 @@ function surfaceSession(): Session {
|
||||
return s
|
||||
}
|
||||
|
||||
function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: seq,
|
||||
data: { content: [], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
describe('foldSurface provenance', () => {
|
||||
it('accepts absent or valid provenance and complete replacement coverage', () => {
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
provenanceEvent(1, undefined),
|
||||
{
|
||||
...provenanceEvent(2, [0, 1]),
|
||||
surfaceOp: { op: 'replace', start: 0, end: 1 },
|
||||
},
|
||||
] as SessionEvent[]
|
||||
expect(() => foldSurface(events)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects provenance on a non-surface event', () => {
|
||||
const event = {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
sourceEventSeqs: [0],
|
||||
} as unknown as SessionEvent
|
||||
expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/],
|
||||
['an empty array', [provenanceEvent(0, [])], /must not be empty/],
|
||||
['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/],
|
||||
['a sparse array', [provenanceEvent(0, Array<number>(1))], /densely contain/],
|
||||
['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/],
|
||||
['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/],
|
||||
['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/],
|
||||
['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/],
|
||||
['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/],
|
||||
['incomplete replacement coverage', [
|
||||
provenanceEvent(0, undefined),
|
||||
provenanceEvent(1, undefined),
|
||||
{ ...provenanceEvent(2, [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } },
|
||||
], /missing 1/],
|
||||
] as const)(
|
||||
'rejects %s',
|
||||
(_name, events, expected) => {
|
||||
expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
@@ -37,7 +101,7 @@ describe('SurfaceManager', () => {
|
||||
it('does not retain fold-only replacement history in incremental state', () => {
|
||||
const s = new Session(SessionId('incremental-state'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
|
||||
|
||||
expect(s.surface.nodes).toEqual([1])
|
||||
const manager = s.surface as unknown as { _state: object }
|
||||
@@ -48,12 +112,29 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('foldSurface reports the same invalid replacement failures as the incremental manager', () => {
|
||||
const s = new Session(SessionId('shared-fold-invalid'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] })
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
{ ...provenanceEvent(1, [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } },
|
||||
] as SessionEvent[]
|
||||
|
||||
expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/)
|
||||
expect(() => s.surface.nodes).toThrow(/start seq 42 not found/)
|
||||
expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
|
||||
expect(() => new Session(SessionId('shared-fold-invalid'), events))
|
||||
.toThrow(/start seq 42 not found/)
|
||||
})
|
||||
|
||||
it('leaves incremental state unchanged when candidate validation fails', () => {
|
||||
const s = new Session(SessionId('atomic-validation'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
|
||||
expect(() => s.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
)).toThrow(/missing 0/)
|
||||
|
||||
expect(s.events).toHaveLength(1)
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(s.surface.nodes).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
|
||||
@@ -65,7 +146,20 @@ describe('SurfaceManager', () => {
|
||||
}
|
||||
|
||||
expect(() => foldSurface([malformed]))
|
||||
.toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/)
|
||||
.toThrow(/surface-eligible and requires a surfaceOp marker/)
|
||||
})
|
||||
|
||||
it('foldSurface rejects surfaceOp on a non-surface event', () => {
|
||||
const malformed = {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent
|
||||
|
||||
expect(() => foldSurface([malformed]))
|
||||
.toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
})
|
||||
|
||||
it('folds an ordered sequence list from surfaceOp: append markers', () => {
|
||||
@@ -143,21 +237,19 @@ describe('SurfaceManager', () => {
|
||||
it('throws when replace start is not found', () => {
|
||||
const s = new Session(SessionId('bad-start'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('assistant/message',
|
||||
expect(() => s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/)
|
||||
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] },
|
||||
)).toThrow(/surface replace: start seq 5 not found/)
|
||||
})
|
||||
|
||||
it('throws when replace end is not found', () => {
|
||||
const s = new Session(SessionId('bad-end'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('assistant/message',
|
||||
expect(() => s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/)
|
||||
)).toThrow(/surface replace: end seq 99 not found/)
|
||||
})
|
||||
|
||||
it('throws when start is after end', () => {
|
||||
@@ -165,22 +257,22 @@ describe('SurfaceManager', () => {
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
||||
// start=1, end=0 would be reversed order.
|
||||
s.append('assistant/message',
|
||||
expect(() => s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/)
|
||||
)).toThrow(/start seq 1.*after end seq 0/)
|
||||
})
|
||||
|
||||
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
|
||||
const s = new Session(SessionId('immutable'))
|
||||
const sources = [10, 20]
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const sources = [0]
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
|
||||
// Mutate caller's array after append.
|
||||
sources.push(30)
|
||||
sources.push(1)
|
||||
sources[0] = 99
|
||||
const logged = s.events[0]! as SurfaceEvent
|
||||
expect(logged.sourceEventSeqs).toEqual([10, 20])
|
||||
const logged = s.events[1]! as SurfaceEvent
|
||||
expect(logged.sourceEventSeqs).toEqual([0])
|
||||
})
|
||||
|
||||
it('replace starting at non-head position preserves surrounding order', () => {
|
||||
@@ -255,15 +347,17 @@ 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('step/start', { turn: 1, step: 1 })
|
||||
const event = s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [0, 1] },
|
||||
)
|
||||
expect(event.sourceEventSeqs).toEqual([3, 5, 7])
|
||||
expect(event.sourceEventSeqs).toEqual([0, 1])
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
// The logged event matches the returned event.
|
||||
expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7])
|
||||
expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
|
||||
expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
|
||||
expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
|
||||
|
||||
@@ -240,10 +240,11 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
|
||||
// summary user/message — appended now, so it carries a high log seq.
|
||||
const u1 = seqOf(s, 'user/message')
|
||||
const result = s.events.find(e => e.type === 'tool/result')!.seq
|
||||
const shadowedSeqs = [...s.surface.nodes]
|
||||
s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'CHECKPOINT' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: u1, end: result } })
|
||||
}, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs })
|
||||
// The step's own assistant/message lands AFTER the checkpoint in the log,
|
||||
// still inside the open step.
|
||||
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
|
||||
|
||||
@@ -36,9 +36,10 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContext?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
|
||||
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
|
||||
@@ -47,7 +48,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
|
||||
- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.
|
||||
- `tools/post-execute` may replace content, block with feedback, or attach context; `tools/result` observes the immutable final outcome.
|
||||
- `tools/post-execute` may replace content, block with feedback, or attach ordered contexts; `tools/result` observes the immutable final outcome.
|
||||
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
@@ -101,7 +102,11 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and mid-run `additionalContext` is omitted to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-tools/src/code-mode
|
||||
*/
|
||||
|
||||
import { parse } from 'node:path'
|
||||
import { inspect } from 'node:util'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
@@ -16,11 +17,19 @@ import type { ToolDefinition, ToolRegistry } from './index.ts'
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the
|
||||
* deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its
|
||||
* JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so
|
||||
* this append can never fail on payload shape — whether the sub-call errored, and a
|
||||
* bounded `resultSummary` of its model-facing text.
|
||||
* One bridged sub-dispatch from a `run_code` program: the parent
|
||||
* `run_code` call id, the deterministic sub-call id
|
||||
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
|
||||
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
|
||||
* so this append can never fail on payload shape — whether the sub-call
|
||||
* errored, and a bounded `resultSummary` of its model-facing text. Before
|
||||
* bounding, occurrences of a non-root session workspace path are
|
||||
* normalized to `.` so host-specific absolute path lengths cannot change
|
||||
* the summary.
|
||||
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
|
||||
* model context; persistence and UIs get every call. Appended inside the
|
||||
* parent `run_code`'s execution (the bridge drains its queue before
|
||||
* returning), so the turn-enclosure invariant holds by construction.
|
||||
*/
|
||||
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
|
||||
}
|
||||
@@ -70,9 +79,12 @@ function textOf(content: ContentBlock[]): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */
|
||||
function summarize(text: string): string {
|
||||
return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text
|
||||
/** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */
|
||||
function summarize(text: string, cwd: string | undefined): string {
|
||||
const stableText = cwd === undefined || cwd === parse(cwd).root
|
||||
? text
|
||||
: text.replaceAll(cwd, '.')
|
||||
return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}…` : stableText
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,10 +201,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
parent: exec.token,
|
||||
signal: runController.signal,
|
||||
})
|
||||
for (const context of result.additionalContexts ?? []) {
|
||||
exec.deferContext(context)
|
||||
}
|
||||
const text = textOf(result.content)
|
||||
// Sub-call `additionalContext` is deliberately DROPPED here: the loop's buffering
|
||||
// (append after the step's tool/results) has no safe analogue from inside a running
|
||||
// run_code — injecting now would break tool-call/result adjacency.
|
||||
exec.agent?.session.append('tool/code-dispatch', {
|
||||
parentCallId: exec.callId,
|
||||
subCallId,
|
||||
@@ -202,7 +214,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
// this record from what it actually received.
|
||||
arguments: normalized.logged,
|
||||
isError: result.isError,
|
||||
resultSummary: summarize(text),
|
||||
resultSummary: summarize(text, exec.agent.session.header.cwd),
|
||||
})
|
||||
return { text, isError: result.isError }
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
|
||||
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
|
||||
@@ -207,6 +207,21 @@ export interface ToolExecution extends ToolExecutionInput {
|
||||
readonly token: ToolExecutionToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context handed to a tool implementation after the registry has
|
||||
* accepted a {@link ToolExecution}. A composite tool uses
|
||||
* {@link deferContext} to ferry context produced by nested dispatches back to
|
||||
* the outer result; the loop appends it only after the outer `tool/result`.
|
||||
*/
|
||||
export interface ToolRunContext extends ToolExecution {
|
||||
/**
|
||||
* Defer one nested-dispatch context until this tool's final result reaches
|
||||
* the agent loop. Contexts retain their individual source, envelope, and
|
||||
* metadata and are emitted in call order.
|
||||
*/
|
||||
deferContext(context: HookContext): void
|
||||
}
|
||||
|
||||
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
||||
export interface ToolErrorInfo {
|
||||
name: string
|
||||
@@ -240,7 +255,7 @@ export interface ToolExecutionResult {
|
||||
* Model-facing context for the next request, separate from this tool result.
|
||||
* The loop buffers it until all step results are logged, preserving pairing.
|
||||
*/
|
||||
additionalContext?: HookContext
|
||||
additionalContexts?: HookContext[]
|
||||
/**
|
||||
* The tool-private presentation payload from a successful `execute` (the object
|
||||
* return form). Threaded onto the `tool/result` session event and back into
|
||||
@@ -266,8 +281,8 @@ export type PreToolDecision =
|
||||
* request, or block by turning corrective feedback into an error result.
|
||||
*/
|
||||
export type PostToolDecision =
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
|
||||
/**
|
||||
* Best-effort human-readable message from an arbitrary thrown value: Error
|
||||
@@ -677,6 +692,7 @@ export class ToolRegistry extends Service {
|
||||
* @returns the materialized final result.
|
||||
*/
|
||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
|
||||
const deferredContexts: HookContext[] = []
|
||||
const token = createExecutionToken()
|
||||
const callId = exec.callId
|
||||
const name = exec.name
|
||||
@@ -690,8 +706,11 @@ export class ToolRegistry extends Service {
|
||||
...agent !== undefined ? { agent } : {},
|
||||
...parent !== undefined ? { parent } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
deferContext(context: HookContext): void {
|
||||
deferredContexts.push(context)
|
||||
},
|
||||
}
|
||||
let execution: ToolExecution
|
||||
let execution: ToolRunContext
|
||||
try {
|
||||
const detached = snapshotJsonValue(exec.arguments)
|
||||
if (detached === undefined) {
|
||||
@@ -709,7 +728,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
result = this.materializeFinalResult(await this.executePipeline(execution))
|
||||
result = this.materializeFinalResult(await this.executePipeline(execution, deferredContexts))
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener, guard, or the
|
||||
// waterfall machinery becomes an isError result, never a turn failure.
|
||||
@@ -720,7 +739,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
|
||||
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
private async executePipeline(exec: ToolRunContext, deferredContexts: HookContext[]): Promise<ToolExecutionResult> {
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
|
||||
// approval seam (or degrades to deny) before the monotonic guards run. The
|
||||
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
|
||||
@@ -774,7 +793,16 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
},
|
||||
)
|
||||
return await this.postExecute(exec, result)
|
||||
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
additionalContexts: [
|
||||
...deferredContexts,
|
||||
...result.additionalContexts ?? [],
|
||||
],
|
||||
}
|
||||
return await this.postExecute(exec, resultWithDeferredContexts)
|
||||
}
|
||||
|
||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
||||
@@ -836,8 +864,11 @@ export class ToolRegistry extends Service {
|
||||
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
||||
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
||||
* `content` when given), `block` turns it into an `isError` whose content is
|
||||
* the corrective `feedback`. Either decision may attach `additionalContext`,
|
||||
* which is ferried on the returned result for the loop's per-step buffer.
|
||||
* the corrective `feedback`. Either decision may attach `additionalContexts`,
|
||||
* which are ferried on the returned result for the loop's per-step buffer.
|
||||
* Context deferred by the tool body survives an accepted result but is
|
||||
* discarded when the outer call is blocked; a block exposes only context the
|
||||
* blocking decision explicitly supplied.
|
||||
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
|
||||
*/
|
||||
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
|
||||
@@ -845,19 +876,24 @@ export class ToolRegistry extends Service {
|
||||
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
||||
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
||||
)
|
||||
const additionalContext = decision.additionalContext
|
||||
const decisionContexts = decision.additionalContexts ?? []
|
||||
if (decision.kind === 'block') {
|
||||
return {
|
||||
content: decision.feedback,
|
||||
isError: true,
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
|
||||
}
|
||||
}
|
||||
// Accept: replace content if supplied and preserve the dispatched outcome.
|
||||
// Accept: replace content if supplied, preserve the dispatched outcome, and
|
||||
// append decision contexts after contexts deferred by the tool body.
|
||||
const additionalContexts = [
|
||||
...result.additionalContexts ?? [],
|
||||
...decisionContexts,
|
||||
]
|
||||
return {
|
||||
...result,
|
||||
...decision.content ? { content: decision.content } : {},
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
|
||||
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
|
||||
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -289,7 +289,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* content only) or a `{ content, meta }` object to also attach a tool-private
|
||||
* presentation payload (see {@link ToolExecuteReturn}).
|
||||
*/
|
||||
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI (an editor
|
||||
* tool-call card, a CLI log line). `args` is the typed, schema-validated
|
||||
@@ -333,7 +333,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
|
||||
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
// isError result so the model can self-correct. After this guard, the
|
||||
|
||||
@@ -82,10 +82,11 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] {
|
||||
}
|
||||
|
||||
/** A structural fake of the owning agent: captures session appends. */
|
||||
function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } {
|
||||
function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent: Agent; events: { type: string; data: unknown }[] } {
|
||||
const events: { type: string; data: unknown }[] = []
|
||||
const agent = {
|
||||
session: {
|
||||
header: options.cwd === undefined ? {} : { cwd: options.cwd },
|
||||
append: (type: string, data: unknown) => { events.push({ type, data }) },
|
||||
},
|
||||
} as unknown as Agent
|
||||
@@ -477,27 +478,71 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
it('suppresses sub-call additionalContext (deliberately; pinned)', async () => {
|
||||
it('defers sub-call additionalContexts onto the outer run_code result', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
registerEcho(ctx)
|
||||
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name === 'echo') {
|
||||
return Promise.resolve({
|
||||
kind: 'accept' as const,
|
||||
additionalContext: { content: [{ type: 'text' as const, text: 'context for the next request' }], source: { kind: 'plugin' as const, plugin: 'test' } },
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
|
||||
source: { kind: 'plugin' as const, plugin: 'test' },
|
||||
envelope: 'raw' as const,
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
})
|
||||
}
|
||||
return next()
|
||||
})
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.echo!({ value: 'x' })
|
||||
await request.bindings[0]!.functions.echo!({ value: 'y' })
|
||||
return { logs: [], value: 'done' }
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.isError).toBe(false)
|
||||
// The sub-call's context has no safe outlet mid-run; the parent result
|
||||
// must not carry it either.
|
||||
expect(result.additionalContext).toBeUndefined()
|
||||
expect(result.additionalContexts).toEqual([
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:1' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:1' },
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:2' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:2' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps sub-call contexts when run_code fails after the nested dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'both' })
|
||||
registerEcho(ctx)
|
||||
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name !== 'echo') return next()
|
||||
return Promise.resolve({
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: 'nested context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}],
|
||||
})
|
||||
})
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.echo!({ value: 'x' })
|
||||
return { logs: [], error: { kind: 'exception', message: 'program failed later' } }
|
||||
}
|
||||
|
||||
const result = await runCode(ctx, 'program')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.additionalContexts).toEqual([{
|
||||
content: [{ type: 'text', text: 'nested context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {
|
||||
@@ -671,6 +716,52 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes the session workspace root before bounding durable result summaries', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'workspace_path',
|
||||
description: 'Return a path beneath the session workspace.',
|
||||
parameters: {},
|
||||
execute(_args, exec) {
|
||||
const cwd = exec.agent?.session.header.cwd ?? ''
|
||||
return Promise.resolve([{ type: 'text' as const, text: `<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}` }])
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async request => ({
|
||||
logs: [],
|
||||
value: await request.bindings[0]!.functions.workspace_path!({}),
|
||||
})
|
||||
|
||||
const short = fakeAgent({ cwd: '/tmp/workspace' })
|
||||
const long = fakeAgent({ cwd: `/tmp/${'long-segment/'.repeat(30)}workspace` })
|
||||
const shortResult = await runCode(ctx, 'program', { agent: short.agent })
|
||||
const longResult = await runCode(ctx, 'program', { agent: long.agent })
|
||||
const shortDispatch = short.events[0]!.data as SessionEventMap['tool/code-dispatch']
|
||||
const longDispatch = long.events[0]!.data as SessionEventMap['tool/code-dispatch']
|
||||
|
||||
expect(shortResult.content).not.toEqual(longResult.content)
|
||||
expect(shortDispatch.resultSummary).toBe(longDispatch.resultSummary)
|
||||
expect(shortDispatch.resultSummary).toHaveLength(201)
|
||||
expect(shortDispatch.resultSummary).toMatch(/^<path>\.\/nested\/task\.txt<\/path>\n.+…$/)
|
||||
})
|
||||
|
||||
it('leaves result summaries unchanged when a session cwd is absent or is the filesystem root', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
registerEcho(ctx)
|
||||
runtime.behavior = async request => ({
|
||||
logs: [],
|
||||
value: await request.bindings[0]!.functions.echo!({ value: '/workspace/value' }),
|
||||
})
|
||||
|
||||
const absent = fakeAgent({})
|
||||
const root = fakeAgent({ cwd: '/' })
|
||||
await runCode(ctx, 'program', { agent: absent.agent })
|
||||
await runCode(ctx, 'program', { agent: root.agent })
|
||||
|
||||
expect((absent.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
|
||||
expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
|
||||
})
|
||||
|
||||
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -348,7 +348,7 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
|
||||
})
|
||||
|
||||
it('a block decision can ALSO attach additionalContext', async () => {
|
||||
it('a block decision can ALSO attach additionalContexts', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -356,24 +356,95 @@ describe('ToolRegistry', () => {
|
||||
({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'rejected' }],
|
||||
additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }],
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'rejected' })
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
|
||||
})
|
||||
|
||||
it('a post-execute additionalContext rides on the result for the loop to buffer', async () => {
|
||||
it('post-execute additionalContexts ride on the result for the loop to buffer', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } }))
|
||||
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
|
||||
})
|
||||
|
||||
it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'composite',
|
||||
description: 'composite',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
const result = await next()
|
||||
return {
|
||||
...result,
|
||||
additionalContexts: [
|
||||
...result.additionalContexts ?? [],
|
||||
{ content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' } },
|
||||
],
|
||||
}
|
||||
})
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
||||
const downstream = await next()
|
||||
return {
|
||||
...downstream,
|
||||
additionalContexts: [
|
||||
{ content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' } },
|
||||
...downstream.additionalContexts ?? [],
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} })
|
||||
|
||||
expect(result.additionalContexts?.map(context => context.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'nested-1' },
|
||||
{ kind: 'plugin', plugin: 'nested-2' },
|
||||
{ kind: 'plugin', plugin: 'wrapper' },
|
||||
{ kind: 'plugin', plugin: 'post' },
|
||||
])
|
||||
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
|
||||
expect(result.additionalContexts?.[1]?.envelope).toBe('raw')
|
||||
})
|
||||
|
||||
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'failing-composite',
|
||||
description: 'failing composite',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' } })
|
||||
throw new Error('outer failure')
|
||||
},
|
||||
}))
|
||||
|
||||
const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} })
|
||||
expect(failed.isError).toBe(true)
|
||||
expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
|
||||
|
||||
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'blocked' }],
|
||||
additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }],
|
||||
}))
|
||||
const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
|
||||
})
|
||||
|
||||
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
|
||||
@@ -532,25 +603,25 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
||||
})
|
||||
|
||||
it('preserves additionalContext supplied by an around-dispatch result', async () => {
|
||||
it('preserves additionalContexts supplied by an around-dispatch result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => ({
|
||||
content: [{ type: 'text', text: 'short-circuited with context' }],
|
||||
isError: false,
|
||||
additionalContext: {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
},
|
||||
}],
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('around-context'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.additionalContext).toEqual({
|
||||
expect(result.additionalContexts).toEqual([{
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
}])
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||
|
||||
Reference in New Issue
Block a user