refactor(agent): remove message metadata channel

This commit is contained in:
_Kerman
2026-07-24 14:05:33 +08:00
parent 7d5c8b12c0
commit 5c7505b208
55 changed files with 1610 additions and 502 deletions

View File

@@ -1,8 +1,9 @@
/**
* The concrete Agent, in the naive-agent shape: the agent IS the machine.
* Two inboxes — `queued` (prompts, one turn each) and `outbox` (steering +
* injected context, taken whole at every step boundary) — and one `run()`
* per turn: intake the prompt, then step until the model owes no response.
* injected context, taken whole at every step boundary). `kick()` admits and
* records one queued prompt; `start()` then steps until the model owes no
* response.
*
* The session log IS the transcript: every take appends, every step re-derives
* (`session.deriveMessages()`), so editing history between steps is naturally
@@ -38,7 +39,7 @@ import type {
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message, MessageSource,
} from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals, snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, PromptMessageData, Session, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, SessionId, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
@@ -50,7 +51,6 @@ interface QueuedMessage {
source: MessageSource
contexts: HookContext[]
wakeup: boolean
meta?: JsonValue
}
/** Input awaiting the next step boundary. */
@@ -58,6 +58,13 @@ type OutboxItem =
| ({ kind: 'steering' } & QueuedMessage)
| { kind: 'context'; context: HookContext }
/** Mutable settlement facts shared by one turn's intake and step loop. */
interface TurnState {
turn: number
step: number
reason: TurnEndReason
}
/** Build one live inbox event payload from an accepted message. */
function inboxMessage(message: QueuedMessage, steering: boolean): AgentMessage {
return {
@@ -96,7 +103,6 @@ function preparePromptMessage(
displayContent: content,
prefixContexts: prefixContexts.map(context => ({
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
})),
},
},
@@ -211,7 +217,6 @@ export class ReactLoopAgent extends Agent {
source: options.source ?? { kind: 'user' },
contexts: options.contexts ?? [],
wakeup,
...options.meta === undefined ? {} : { meta: options.meta },
})
if (steering) this.outbox.push({ kind: 'steering', ...accepted })
else this.queued.push(accepted)
@@ -225,7 +230,6 @@ export class ReactLoopAgent extends Agent {
const context = this.accept({
content,
source: options.source ?? { kind: 'plugin', plugin: '' },
...options.meta === undefined ? {} : { meta: options.meta },
})
if (this.turnAbort !== undefined) {
this.outbox.push({ kind: 'context', context })
@@ -280,7 +284,7 @@ export class ReactLoopAgent extends Agent {
*/
retry(): void {
if (this.turnAbort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
this.start()
this.launch({ kind: 'retry' }, (state, signal) => this.start(state, signal))
}
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
@@ -294,18 +298,52 @@ export class ReactLoopAgent extends Agent {
// The machine.
// -------------------------------------------------------------------------
/** Claim the next queued prompt and open a run on it, when nothing is driving. */
/** Claim, admit, and record the next queued prompt before starting its step loop. */
private kick(): void {
if (this.turnAbort !== undefined || !this.queued.some(message => message.wakeup)) return
const message = this.queued.shift()
if (message !== undefined) {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false))
this.start(message)
this.launch({ kind: 'message', source: message.source }, async (state, signal) => {
const decision = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/prompt-submit', this, message.content, message.source, signal,
() => Promise.resolve<PromptDecision>({
kind: 'allow',
...message.contexts.length === 0 ? {} : { additionalContexts: message.contexts },
}),
)
signal.throwIfAborted()
if (decision.kind === 'block') {
this.session.append('prompt/blocked', {
content: message.content,
source: message.source,
reason: decision.reason,
})
state.reason = { kind: 'rejected', reason: decision.reason }
return
}
const prepared = preparePromptMessage(
decision.content ?? message.content,
message.source,
decision.additionalContexts ?? [],
)
this.session.append('user/message', prepared.data, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
this.outbox.push({ kind: 'context', context: this.accept(context) })
}
await this.start(state, signal)
}, true)
}
}
/** Open one `run()` — on a claimed prompt, or promptless for a retry. */
private start(prompt?: QueuedMessage): void {
/** Own one turn from its durable opening through settlement and idle handoff. */
private launch(
trigger: TurnTrigger,
work: (state: TurnState, signal: AbortSignal) => Promise<void>,
deferOpen = false,
): void {
const controller = new AbortController()
this.turnAbort = controller
if (!this.busy) {
@@ -314,94 +352,57 @@ export class ReactLoopAgent extends Agent {
}
// The whole run inherits this agent as its process-local initiator so
// tools, the llm service, and nested factories can attribute their work.
this.done = this.loopCtx.agents.withInitiator(this, () => this.run(prompt, controller))
this.done = this.loopCtx.agents.withInitiator(this, async () => {
const signal = controller.signal
const state: TurnState = {
turn: ++this.lastTurn,
step: 0,
reason: { kind: 'completed' },
}
let idle: IdleReason = { kind: 'completed' }
try {
// A queued claim keeps its established pre-turn cancellation window:
// send() returns before the durable turn opens, while retry starts now.
if (deferOpen) await Promise.resolve()
signal.throwIfAborted()
this.session.append('turn/start', { turn: state.turn, trigger })
this.turnOpen = true
signal.throwIfAborted()
await work(state, signal)
} catch (error: unknown) {
({ reason: state.reason, idle } = this.settle(state.turn, state.step, error, signal))
} finally {
if (this.turnAbort === controller) this.turnAbort = undefined
try {
this.closeTurn(state.turn, state.step, state.reason)
} catch (error: unknown) {
// A rejected boundary append (a pre-commit validation veto) must not
// kill the machine or strand its running interval: report and move on — the
// idle tail below still runs and the next turn still opens.
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${state.turn} failed: ${errorChain(err)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', state.turn, state.step, err)
}
this.idle(state.turn, idle)
}
})
}
/**
* One `run()` is one turn: prompt intake (submit waterfall), the durable
* turn boundary, then the naive step loop until the model owes no response.
* Every failure funnels to the single catch — {@link settle} classifies it
* once (interruption beats error) — and the finally always closes the owed
* boundaries and runs the idle tail, which opens the next run while work
* remains.
*/
private async run(prompt: QueuedMessage | undefined, controller: AbortController): Promise<void> {
const signal = controller.signal
const turn = ++this.lastTurn
let idle: IdleReason = { kind: 'completed' }
let reason: TurnEndReason = { kind: 'completed' }
let step = 0
try {
// Intake precedes the turn: the submit decision belongs to the prompt,
// not the turn (a retry opens a turn with no prompt at all). A failed
// intake leaves no durable trace — nothing entered the conversation.
const decision = prompt === undefined
? undefined
: await this.loopCtx.waterfall(
agentCarrier(this), 'agent/prompt-submit', this, prompt.content, prompt.source, signal,
() => Promise.resolve<PromptDecision>({
kind: 'allow',
...prompt.contexts.length === 0 ? {} : { additionalContexts: prompt.contexts },
}),
)
/** Run the naive step loop after retry or admitted prompt intake has prepared the turn. */
private async start(state: TurnState, signal: AbortSignal): Promise<void> {
while (true) {
state.step += 1
const { owes, maxTokens } = await this.step(state.turn, state.step, signal)
if (maxTokens) state.reason = { kind: 'max-tokens' }
// The naive rule, data-driven: run another step while the model is
// owed a response. On a would-stop boundary, `agent/stopping` gives
// listeners one chance to object — by steering, not by voting — and
// the outbox is re-read: data decides, so listener order cannot.
if (owes || this.outbox.some(item => item.kind === 'steering')) continue
await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, state.turn, signal)
signal.throwIfAborted()
this.session.append('turn/start', {
turn,
trigger: prompt === undefined ? { kind: 'retry' } : { kind: 'message', source: prompt.source },
})
this.turnOpen = true
signal.throwIfAborted()
if (prompt !== undefined && decision?.kind === 'block') {
// The audit record stays turn-enclosed: a zero-step rejected turn.
this.session.append('prompt/blocked', { content: prompt.content, source: prompt.source, reason: decision.reason })
reason = { kind: 'rejected', reason: decision.reason }
} else {
if (prompt !== undefined && decision?.kind === 'allow') {
const prepared = preparePromptMessage(
decision.content ?? prompt.content,
prompt.source,
decision.additionalContexts ?? [],
)
this.session.append('user/message', {
...prepared.data,
...prompt.meta === undefined ? {} : { meta: prompt.meta },
}, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
this.outbox.push({ kind: 'context', context: this.accept(context) })
}
}
while (true) {
step += 1
const { owes, maxTokens } = await this.step(turn, step, signal)
if (maxTokens) reason = { kind: 'max-tokens' }
// The naive rule, data-driven: run another step while the model is
// owed a response. On a would-stop boundary, `agent/stopping` gives
// listeners one chance to object — by steering, not by voting — and
// the outbox is re-read: data decides, so listener order cannot.
if (owes || this.outbox.some(item => item.kind === 'steering')) continue
await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal)
signal.throwIfAborted()
if (!this.outbox.some(item => item.kind === 'steering')) break
}
}
} catch (error: unknown) {
({ reason, idle } = this.settle(turn, step, error, signal))
} finally {
if (this.turnAbort === controller) this.turnAbort = undefined
try {
this.closeTurn(turn, step, reason)
} catch (error: unknown) {
// A rejected boundary append (a pre-commit validation veto) must not
// kill the machine or strand its running interval: report and move on — the
// idle tail below still runs and the next turn still opens.
const err = toError(error)
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(err)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, err)
}
this.idle(turn, idle)
if (!this.outbox.some(item => item.kind === 'steering')) break
}
}
@@ -569,12 +570,8 @@ export class ReactLoopAgent extends Agent {
let steered = false
for (const item of this.outbox.splice(0)) {
if (item.kind === 'context') {
const { content, source, meta } = item.context
this.session.append('user/message', {
content,
source,
...meta === undefined ? {} : { meta },
}, { surfaceOp: 'append' })
const { content, source } = item.context
this.session.append('user/message', { content, source }, { surfaceOp: 'append' })
continue
}
steered = true
@@ -583,15 +580,10 @@ export class ReactLoopAgent extends Agent {
this.session.append('steering/message', {
turn,
...prepared.data,
...item.meta === undefined ? {} : { meta: item.meta },
}, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
const { content, source, meta } = context
this.session.append('user/message', {
content,
source,
...meta === undefined ? {} : { meta },
}, { surfaceOp: 'append' })
const { content, source } = context
this.session.append('user/message', { content, source }, { surfaceOp: 'append' })
}
}
return steered

View File

@@ -92,14 +92,12 @@ describe('agent/prompt-submit', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const meta = { kind: 'prompt-context', version: 1 }
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
additionalContexts: [{
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
source: { kind: 'plugin', plugin: 'test' },
meta,
}],
}))
@@ -112,7 +110,6 @@ describe('agent/prompt-submit', () => {
expect(userMsg).toBeDefined()
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta)
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
})
@@ -133,7 +130,6 @@ describe('agent/prompt-submit', () => {
content: [{ type: 'text', text: 'untrusted prefix' }],
source: { kind: 'plugin', plugin: 'prefix' },
placement: 'prompt-prefix',
meta: { kind: 'prefix-card' },
}],
})
await waitForIdle(ctx, agent)
@@ -151,7 +147,6 @@ describe('agent/prompt-submit', () => {
displayContent: [{ type: 'text', text: 'rewritten request' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'prefix' },
meta: { kind: 'prefix-card' },
}],
},
})
@@ -616,7 +611,6 @@ describe('tool additionalContexts buffering across a step', () => {
additionalContexts: [{
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
source: { kind: 'plugin', plugin: 'p' },
meta: { callId: exec.callId },
}],
}))
@@ -638,7 +632,6 @@ describe('tool additionalContexts buffering across a step', () => {
.flatMap(e => (e.type === 'user/message' ? e.data.content : []))
.map(b => (b.type === 'text' ? b.text : ''))
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
})
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
@@ -647,8 +640,8 @@ describe('tool additionalContexts buffering across a step', () => {
ctx.tools.register(defineContentToolFixture({
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' }, meta: { order: 2 } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' } })
return [{ type: 'text', text: 'outer result' }]
},
}))
@@ -666,7 +659,6 @@ describe('tool additionalContexts buffering across a step', () => {
{ kind: 'plugin', plugin: 'a' },
{ kind: 'plugin', plugin: 'b' },
])
expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
})
})

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -398,26 +398,20 @@ describe('agent loop', () => {
expect(flat).not.toContain('<context source=')
})
it('inject() persists structured context content verbatim with durable hidden meta', async () => {
it('inject() persists structured context content verbatim with durable source', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', 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' },
meta,
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta })
expect(contextEvent?.type === 'user/message' && contextEvent.data.source)
.toEqual({ kind: 'plugin', plugin: 'workspace-context' })
const requestText = JSON.stringify(adapter.requests[0]!.messages)
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
expect(requestText).not.toContain('<context source=')
@@ -431,7 +425,6 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let visibleDuringTool = false
const meta = { kind: 'deferred-test', version: 1 }
ctx.tools.register(defineTool({
name: 'noticer',
description: 'injects a notice',
@@ -441,7 +434,6 @@ describe('agent loop', () => {
const first = { type: 'text' as const, text: 'mid-turn notice' }
agent.inject([first], {
source: { kind: 'plugin', plugin: 'x' },
meta,
})
first.text = 'mutated after inject'
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
@@ -501,8 +493,7 @@ describe('agent loop', () => {
async execute() {
expect(() => {
agent.inject([{ type: 'text', text: 'invalid' }], {
source: { kind: 'plugin', plugin: 'test' },
meta: { bigint: 1n },
source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never,
})
}).toThrow('agent context must be losslessly JSON-serializable')
return [{ type: 'text', text: 'rejected invalid context' }]
@@ -515,28 +506,6 @@ describe('agent loop', () => {
expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
})
it('preserves SendOptions.meta on the durable user/message and steering/message', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'noop', description: '', parameters: {},
async execute() {
// Running steer carries its own meta onto the durable steering/message.
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'p' }, meta: { steer: 1 } })
return []
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'go' }], { target: 'next-turn', wakeup: true, meta: { prompt: 1 } })
await waitForIdle(ctx, agent)
const user = agent.session.events.find(e => e.type === 'user/message')
expect(user?.type === 'user/message' && user.data.meta).toEqual({ prompt: 1 })
const steering = agent.session.events.find(e => e.type === 'steering/message')
expect(steering?.type === 'steering/message' && steering.data.meta).toEqual({ steer: 1 })
})
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
// force-continue: model never calls tools, but a plugin forces 3 steps
const adapter = new MockAdapter([

View File

@@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `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. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
`PromptDecision.additionalContexts` is an array so every context keeps its own source and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context sources for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached contexts.
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).
@@ -59,7 +59,7 @@ The handle every plugin programs against:
- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. `target: 'next-turn'` (default) queues one independent FIFO item that, if claimed, becomes the sole ordinary message in its turn; `wakeup` (default `true`) wakes a parked driver, while `wakeup: false` queues without waking. `target: 'next-step'` with `wakeup: true` submits steering, and with `wakeup: false` injects durable context without running the model. Omitting `options.source` attests direct human input as `{ kind: 'user' }` (injection defaults to `{ kind: 'plugin', plugin: '' }`) and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/inbox/enqueue` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: accept detached in-session context without running the model; the next request sees its `user/message` with `content` rendered verbatim as user-role input and provenance carried entirely by `source`. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`

View File

@@ -26,7 +26,7 @@ import type { Context } from 'cordis'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
@@ -78,8 +78,6 @@ export interface SendOptions {
* records them directly at its next checkpoint.
*/
contexts?: HookContext[]
/** Opaque JSON state retained on the durable message but hidden from the model. */
meta?: JsonValue
}
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
@@ -106,9 +104,7 @@ export function AgentMessageId(id: string): AgentMessageId {
* message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with. `steering`
* is true for a `next-step` item drained between steps; a `next-turn` item is
* claimed at a turn boundary. `SendOptions.meta` is intentionally omitted: it is
* durable model-hidden state that lands on the eventual `user/message`/
* `steering/message`, not live-event routing data.
* claimed at a turn boundary.
*/
export interface AgentMessage {
/** The id `send` returned for this message. */
@@ -150,8 +146,6 @@ export interface HookContext {
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}
/**
@@ -414,7 +408,7 @@ declare module 'cordis' {
* @param step - the step whose request this is.
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode compose
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**

View File

@@ -12,20 +12,16 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'agent/created': args => args[0],
'agent/disposed': args => args[0],
'agent/error': args => args[0],
'agent/idle': args => args[0],
'agent/inbox/dequeue': args => args[0],
'agent/inbox/discard': args => args[0],
'agent/inbox/enqueue': args => args[0],
'agent/post-step': args => args[0],
'agent/pre-step': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/request': args => args[0],
'agent/request-error': args => args[0],
'agent/session-prefix': args => args[0],
'agent/session-start': args => args[0],
'agent/status': args => args[0],
'agent/step-result': args => args[0],
'agent/turn-continuation': args => args[0],
'agent/turn-stop': args => args[0],
'agent/step': args => args[0],
'agent/stopping': args => args[0],
'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
'goal/changed': args => args[0],
'session/created': null,

View File

@@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
`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 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt (`user` source), a synthetic injection (`plugin`/`goal` source), or an admitted goal round — `source` is the only channel that tells them apart. It may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context sources. `displayPromptContent()` selects the human-facing prompt without changing derived history.
`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`.

View File

@@ -187,8 +187,6 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
export interface PromptPrefixContext {
/** Producer provenance retained for transcript presentation and inspection. */
source: MessageSource
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}
/**
@@ -209,7 +207,7 @@ export interface PromptMessageEnvelope {
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content;
* they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type. `meta` carries durable model-hidden producer state.
* not by event type.
*/
export interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
@@ -218,15 +216,6 @@ export interface PromptMessageData {
source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
/**
* Opaque durable JSON state retained on the event but hidden from the model
* projection. It is the intended channel for a future framing directive (a
* producer declares the frame, a dedicated renderer applies it — see the
* deferred note in
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
* so the surface keeps projecting `content` verbatim rather than wrapping it.
*/
meta?: JsonValue
}
/**

View File

@@ -147,7 +147,7 @@ describe('Session', () => {
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'question' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' }, meta: { kind: 'card' } }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' } }],
},
}, { surfaceOp: 'append' })
@@ -165,17 +165,11 @@ describe('Session', () => {
.toEqual(session.deriveMessages())
})
it('keeps context meta durable in the event while hiding it from the projection', () => {
it('keeps context source durable in the event while hiding it from the projection', () => {
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('user/message', {
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta,
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual([{
@@ -183,7 +177,7 @@ describe('Session', () => {
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
}])
const event = session.events[0]
expect(event?.type === 'user/message' && event.data.meta).toEqual(meta)
expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
})
it('replays identically from a seeded event log', () => {

View File

@@ -878,7 +878,7 @@ describe('ToolRegistry', () => {
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-1' }], source: { kind: 'plugin', plugin: 'nested-1' } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' } })
return [{ type: 'text', text: 'done' }]
},
@@ -912,7 +912,6 @@ describe('ToolRegistry', () => {
{ kind: 'plugin', plugin: 'wrapper' },
{ kind: 'plugin', plugin: 'post' },
])
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
})
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {