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

@@ -74,7 +74,6 @@ export interface ContextMessageNode {
seq: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** A tool result paired (when in-window) with its call head. */

View File

@@ -43,7 +43,6 @@ function materializeNode(
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
}
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }

View File

@@ -43,7 +43,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
case 'context':
return (
<div className={css.contextRow}>
<JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} />
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
</div>
)
default:

View File

@@ -33,7 +33,7 @@ describe('MessageItem arms', () => {
it('context and unknown nodes render their JSON rows', () => {
const ctxView = render(
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null, meta: { k: 1 } } as never} />,
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
)
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
const unknownView = render(

View File

@@ -12,7 +12,7 @@
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay.
The context uses a typed `{ kind: 'session-reference', ... }` source with `placement: 'prompt-prefix'`. That source records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and source for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay.
## Configuration

View File

@@ -9,7 +9,7 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import {
DEFAULT_CANDIDATE_LIMIT,
@@ -20,7 +20,7 @@ import {
} from './config.ts'
import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts'
import { stringifyTagSafeJson } from './serialization.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput } from './types.ts'
import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts'
export type * from './types.ts'
export type { Config, SessionReferenceErrorCode } from './config.ts'
@@ -181,7 +181,7 @@ export class SessionReferenceService extends Service {
const rendered = this.renderSources(prepared)
const prompt = renderPrompt(rendered.map(source => source.data))
const meta = {
const source: SessionReferenceSource = {
kind: 'session-reference',
version: 1,
references: rendered.map((source, index) => ({
@@ -191,12 +191,11 @@ export class SessionReferenceService extends Service {
...source.stats,
inputIndex: index,
})),
} satisfies JsonValue
}
const context: HookContext = {
source: { kind: 'plugin', plugin: 'session-reference' },
source,
content: [{ type: 'text', text: prompt }],
placement: 'prompt-prefix',
meta,
}
return { content: acceptedContent, contexts: [context] }
}

View File

@@ -4,6 +4,30 @@ import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Durable provenance for one prepared cross-session context. */
export interface SessionReferenceSource {
kind: 'session-reference'
version: 1
references: {
sessionId: string
label: string
capturedThroughSeq: number | null
compacted: boolean
originalMessages: number
retainedMessages: number
omittedMessages: number
omittedBytes: number
truncated: boolean
inputIndex: number
}[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'session-reference': SessionReferenceSource
}
}
/** One source session selected by a host. */
export interface SessionReferenceInput {
/** Opaque source session identity. */

View File

@@ -241,7 +241,7 @@ describe('session reference discovery and preparation', () => {
expect(prepared.contexts).toHaveLength(1)
const context = prepared.contexts[0]
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' })
expect(context.source).toMatchObject({ kind: 'session-reference' })
expect(context.placement).toBe('prompt-prefix')
expect(context.content[0].text).toContain('untrusted, read-only snapshot')
expect(promptData(context.content[0].text)).toEqual([{
@@ -256,7 +256,7 @@ describe('session reference discovery and preparation', () => {
{ role: 'assistant', text: 'visible answer' },
],
}])
expect(context.meta).toMatchObject({
expect(context.source).toMatchObject({
kind: 'session-reference',
version: 1,
references: [{
@@ -354,7 +354,7 @@ describe('session reference discovery and preparation', () => {
{ sessionId: one.id, label: 'first' },
{ sessionId: one.id, label: 'ignored duplicate' },
{ sessionId: two.id },
])).resolves.toMatchObject({ contexts: [{ meta: { references: [{ label: 'first' }, { label: 'two' }] } }] })
])).resolves.toMatchObject({ contexts: [{ source: { references: [{ label: 'first' }, { label: 'two' }] } }] })
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [null as never]))
@@ -432,7 +432,7 @@ describe('session reference discovery and preparation', () => {
expect(context.content[0].text).toContain('checkpoint')
expect(context.content[0].text).toContain('latest-')
expect(context.content[0].text).toContain('omitted')
expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] })
expect(context.source).toMatchObject({ references: [{ truncated: true, compacted: true }] })
})
it('applies the full byte limit independently to each of three references', async () => {
@@ -501,7 +501,6 @@ describe('session reference discovery and preparation', () => {
displayContent: prepared.content,
prefixContexts: [{
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
}],
},
}, { surfaceOp: 'append' })

View File

@@ -46,9 +46,9 @@ The plugin owns the complete `<system-reminder>` framing, and every `context/mes
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter the source, pending state, and version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates only the provider cache.
The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
@@ -73,7 +73,7 @@ The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in the structured message source.
## Model Experience

View File

@@ -99,7 +99,6 @@ export function apply(ctx: Context, config: Config): void {
if (update !== undefined) {
agent.inject(update.context.content, {
source: update.context.source,
meta: update.context.meta,
})
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
}

View File

@@ -15,7 +15,7 @@ export const name = 'workspace-context-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace metadata,
* No runtime invariant: replay intentionally tolerates unknown or malformed workspace sources,
* while focused pipeline tests own its private pending/cache state transitions.
*/
const install: InvariantInstaller = () => {}

View File

@@ -6,7 +6,7 @@
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
@@ -33,9 +33,20 @@ import {
export const name = 'workspace-context'
const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const
const FILE_TOUCH_TOOL_NAMES = new Set(['read', 'write', 'edit'])
/** Durable provenance and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
changes: WorkspaceInstructionChange[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'workspace-instructions': WorkspaceInstructionSource
}
}
/** Dynamic state waiting for the loop to append its returned context event. */
export interface PendingInstructionChange {
change: WorkspaceInstructionChange
@@ -70,20 +81,14 @@ export interface ReconciledInstructionContext {
versionUpdates: InstructionVersionUpdate[]
}
/** Plugin-owned context with required replay metadata. */
export interface WorkspaceHookContext extends HookContext {
meta: JsonValue
}
/** Plugin-owned workspace context. */
export type WorkspaceHookContext = HookContext
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): WorkspaceHookContext {
const serializedChanges: JsonValue[] = changes.map(change => ({
action: change.action,
scope: change.scope,
path: change.path,
...change.digest !== undefined ? { digest: change.digest } : {},
}))
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta }
return {
content: [{ type: 'text', text }],
source: { kind: 'workspace-instructions', changes },
}
}
/**
@@ -103,20 +108,20 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(source: unknown): source is typeof PLUGIN_SOURCE {
function isWorkspaceContextSource(source: unknown): source is WorkspaceInstructionSource {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'plugin'
&& 'plugin' in source && source.plugin === name
&& 'kind' in source && source.kind === 'workspace-instructions'
&& 'changes' in source && Array.isArray(source.changes)
}
function isRecord(value: JsonValue | undefined): value is { [key: string]: JsonValue } {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInstructionChange[] {
if (!isRecord(meta) || meta.kind !== 'workspace-instructions' || meta.version !== 1 || !Array.isArray(meta.changes)) return []
function workspaceInstructionChanges(source: unknown): WorkspaceInstructionChange[] {
if (!isWorkspaceContextSource(source)) return []
const changes: WorkspaceInstructionChange[] = []
for (const value of meta.changes) {
for (const value of source.changes) {
if (!isRecord(value)) continue
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
@@ -146,7 +151,7 @@ function visibleInstructionChanges(
const visible = new Map<string, WorkspaceInstructionChange>()
for (const [seq, event] of agent.session.events.entries()) {
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.meta)
const changes = workspaceInstructionChanges(event.data.source)
for (const change of changes) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
@@ -283,7 +288,7 @@ export function observeInstructionSessionEvent(
switch (event.type) {
case 'user/message': {
if (!isWorkspaceContextSource(event.data.source)) return
for (const change of workspaceInstructionChanges(event.data.meta)) {
for (const change of workspaceInstructionChanges(event.data.source)) {
const waiting = pending.get(change.scope)
if (waiting !== undefined && event.seq >= waiting.afterSeq && sameInstructionChange(waiting.change, change)) {
pending.delete(change.scope)
@@ -329,7 +334,7 @@ export function commitPendingInstructionContexts(
const step = openStep(agent.session)
for (const context of contexts ?? []) {
if (!isWorkspaceContextSource(context.source)) continue
const changes = workspaceInstructionChanges(context.meta)
const changes = workspaceInstructionChanges(context.source)
if (changes.length === 0) continue
const pending = pendingChangesFor(agent.session, pendingBySession)
for (const change of changes) {

View File

@@ -108,11 +108,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
const events = [...live.agent.session.events]
const update = events.find(event => event.type === 'user/message'
&& typeof event.data.meta === 'object'
&& event.data.meta !== null
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
&& event.data.source.kind === 'workspace-instructions')
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
const updateText = update?.type === 'user/message'

View File

@@ -184,7 +184,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
...options?.meta !== undefined ? { meta: options.meta } : {},
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
@@ -206,16 +205,14 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
function workspaceContextOf(result: { additionalContexts?: HookContext[] }): HookContext | undefined {
return result.additionalContexts?.find(context =>
context.source.kind === 'plugin' && context.source.plugin === 'workspace-context')
context.source.kind === 'workspace-instructions')
}
function workspaceChangeContext(scope: string, digest: string): HookContext {
return {
content: [{ type: 'text', text: `instructions for ${scope}` }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: {
source: {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope, path: `${scope}/AGENTS.md`, digest }],
},
}
@@ -227,7 +224,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H
lastSeq = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
}
return lastSeq
@@ -930,7 +926,7 @@ describe('workspace context request injection', () => {
kind: 'accept' as const,
}))
expect(accepted.kind).toBe('accept')
expect(workspaceContextOf(accepted)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(accepted)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(blocksText(workspaceContextOf(accepted)?.content)).toContain('nested package rule')
} finally {
await ctx.fiber.dispose()
@@ -1050,7 +1046,7 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Updated instructions from: AGENTS.md')
@@ -1079,7 +1075,7 @@ describe('workspace context request injection', () => {
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('Instructions removed: AGENTS.md')
@@ -1810,19 +1806,18 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
version: 1,
changes: [{
action: 'set',
scope: sk('pkg', 'AGENTS.md'),
path: join('pkg', 'AGENTS.md'),
}],
})
const meta = workspaceContextOf(result)?.meta
const firstChange = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes[0]
const source = workspaceContextOf(result)?.source
const firstChange = source?.kind === 'workspace-instructions'
? source.changes[0]
: undefined
const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
? firstChange.digest
@@ -1901,9 +1896,9 @@ describe('dynamic nested workspace context injection', () => {
agent: stubAgent(root),
})
const meta = workspaceContextOf(result)?.meta
const changes = typeof meta === 'object' && meta !== null && !Array.isArray(meta) && Array.isArray(meta.changes)
? meta.changes
const source = workspaceContextOf(result)?.source
const changes = source?.kind === 'workspace-instructions'
? source.changes
: []
expect(changes).toEqual(expect.arrayContaining([
expect.objectContaining({ action: 'set', path: join('pkg', 'AGENTS.md') }),
@@ -2122,7 +2117,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(changed)?.meta).toMatchObject({
expect(workspaceContextOf(changed)?.source).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -2168,7 +2163,7 @@ describe('dynamic nested workspace context injection', () => {
})
// Removing one candidate only removes its own scope; the sibling scope is untouched.
expect(workspaceContextOf(removed)?.meta).toMatchObject({
expect(workspaceContextOf(removed)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
@@ -2196,7 +2191,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent,
})
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
const text = blocksText(workspaceContextOf(result)?.content)
@@ -2276,7 +2271,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
expect(workspaceContextOf(converged)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') }],
})
expect(blocksText(workspaceContextOf(converged)?.content)).toContain(`Instructions removed: ${join('pkg', 'CLAUDE.md')}`)
@@ -2310,7 +2305,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(converged)?.meta).toMatchObject({
expect(workspaceContextOf(converged)?.source).toMatchObject({
changes: [
{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') },
{ action: 'remove', scope: sk('pkg', 'CLAUDE.md'), path: join('pkg', 'CLAUDE.md') },
@@ -2347,9 +2342,8 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(removed)?.meta).toEqual({
expect(workspaceContextOf(removed)?.source).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
@@ -2394,7 +2388,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(removed)?.meta).toMatchObject({
expect(workspaceContextOf(removed)?.source).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toContain(`Instructions removed: ${join('pkg', 'AGENTS.md')}`)
@@ -2433,7 +2427,7 @@ describe('dynamic nested workspace context injection', () => {
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent,
})
expect(workspaceContextOf(restored)?.meta).toMatchObject({
expect(workspaceContextOf(restored)?.source).toMatchObject({
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
@@ -2537,7 +2531,7 @@ describe('dynamic nested workspace context injection', () => {
await composeBaselinePrefix(ctx, resumed)
const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user')
expect(update?.type === 'user/message' && update.data.meta).toMatchObject({
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
expect(update?.type === 'user/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
@@ -2691,31 +2685,23 @@ describe('dynamic nested workspace context injection', () => {
{ type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' },
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: {
source: {
kind: 'workspace-instructions',
version: 1,
changes: [
null,
{ action: 'unknown', scope: 'pkg', path: join('pkg', 'AGENTS.md') },
{ action: 'set', scope: 'pkg', path: 42 },
{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 42 },
],
},
} as never,
}, { surfaceOp: 'append' })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'stale metadata version' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
meta: { kind: 'workspace-instructions', version: 0, changes: [] },
source: { kind: 'workspace-instructions', changes: 'invalid' } as never,
}, { surfaceOp: 'append' })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'foreign plugin context' }],
source: { kind: 'plugin', plugin: 'other' },
meta: {
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md'), digest: 'spoof' }],
},
}, { surfaceOp: 'append' })
const result = await ctx.tools.execute({
@@ -2884,8 +2870,8 @@ describe('dynamic nested workspace context injection', () => {
})
expect(blocksText(result.content)).toContain('downstream replacement')
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
expect(workspaceContextOf(result)?.source).toMatchObject({ kind: 'workspace-instructions' })
expect(workspaceContextOf(result)?.source).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -3246,7 +3232,6 @@ describe('workspace context pending state', () => {
const otherWorkspaceEvent = agent.session.append('user/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions)
expect(pending.get(agent.session)?.has('pkg')).toBe(true)
@@ -3255,7 +3240,6 @@ describe('workspace context pending state', () => {
const confirmed = agent.session.append('user/message', {
content: context.content,
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, confirmed, pending, versions)

View File

@@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the transaction.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */',
jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the lifecycle.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */',
},
{
signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
@@ -857,8 +857,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/cancel-requested',
mode: 'emit',
signature: '\'agent/cancel-requested\'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void',
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted.',
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted.',
},
{
name: 'agent/created',
@@ -878,9 +878,16 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/error',
mode: 'emit',
signature: '\'agent/error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void',
jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
jsDoc: '/**\n * A step or turn errored. The machine reports a failure here (plus the\n * logger) even when the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A step or turn errored.',
},
{
name: 'agent/idle',
mode: 'emit',
signature: '\'agent/idle\'(this: Scoped<Agent>, agent: Agent, turn: number, reason: IdleReason): void',
jsDoc: '/**\n * One turn closed: its `turn/end` and durability flush are already\n * committed. `reason` says why — recovery consumers observe an `error`\n * reason, repair (edit the log, wait, resummon), and call\n * {@link Agent.retry}; UI consumers key turn-done presentation off it.\n * Emitted per turn, including cancelled and failed ones.\n * @param agent - the agent whose turn closed.\n * @param turn - the closed turn number.\n * @param reason - why the turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One turn closed: its `turn/end` and durability flush are already committed.',
},
{
name: 'agent/inbox/dequeue',
mode: 'emit',
@@ -899,51 +906,23 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/inbox/enqueue',
mode: 'emit',
signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void',
jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection\n * (`next-step`/no-wakeup) bypasses the FIFOs and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).',
},
{
name: 'agent/post-step',
mode: 'serial',
signature: '\'agent/post-step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void',
jsDoc: '/**\n * Awaited serial checkpoint after the response, real or synthetic tool\n * results, injected context, and steering are durable but before `step/end`.\n * A cancelled tool batch reaches this checkpoint with an aborted signal.\n * @param agent - the agent whose step is settling.\n * @param turn - the open turn number.\n * @param step - the open step number.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
summary: 'Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`.',
},
{
name: 'agent/pre-step',
mode: 'serial',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void',
jsDoc: '/**\n * Awaited serial checkpoint before `step/start`; appends land outside the\n * pending step and are included when the loop derives request history.\n * `signal` cancels listener work.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent opening the step.\n * @param turn - the open turn number.\n * @param step - the pending step number.\n * @param signal - the turn abort signal.\n * @mode serial\n */',
summary: 'Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history.',
jsDoc: '/**\n * A frozen item entered the queued or steering inbox.\n * @param agent - the owning agent.\n * @param message - accepted routing data and correlation identity.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A frozen item entered the queued or steering inbox.',
},
{
name: 'agent/prompt-submit',
mode: 'waterfall',
signature: '\'agent/prompt-submit\'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. A listener wrapping a\n * downstream `allow` must preserve its `content` and `additionalContexts`\n * unless it intentionally replaces them. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it to control another\n * turn. Steering messages do not dispatch this event; they join an open turn\n * at a steering checkpoint.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default, including contexts\n * captured with the queued item. The signal controls only this turn;\n * listeners may cooperate with it but must not retain it for another turn.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.',
},
{
name: 'agent/request',
mode: 'waterfall',
signature: '\'agent/request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * @param signal - the current turn\'s explicit abort signal; ambient\n * initiator identity does not imply liveness or cancellation authority.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
signature: '\'agent/request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Replace the frozen call configuration.',
},
{
name: 'agent/request-error',
mode: 'waterfall',
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>',
jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Recover a model-request failure after its failed step has closed.',
},
{
name: 'agent/session-prefix',
mode: 'waterfall',
signature: '\'agent/session-prefix\'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>',
jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - the current turn\'s explicit abort signal.\n * @mode waterfall\n */',
summary: 'Compose request-only messages placed before derived history.',
},
{
name: 'agent/session-start',
mode: 'emit',
@@ -959,25 +938,18 @@ export const EVENT_API: readonly EventApiEntry[] = [
summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).',
},
{
name: 'agent/step-result',
mode: 'waterfall',
signature: '\'agent/step-result\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>',
jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).',
},
{
name: 'agent/turn-continuation',
mode: 'waterfall',
signature: '\'agent/turn-continuation\'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>',
jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Override whether the turn continues.',
},
{
name: 'agent/turn-stop',
name: 'agent/step',
mode: 'serial',
signature: '\'agent/turn-stop\'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined',
jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.',
signature: '\'agent/step\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void',
jsDoc: '/**\n * Awaited serial checkpoint before EVERY request of a turn is built (the\n * first as well as each post-tools continuation). The single "between\n * steps" seam: inject context, steer, or edit the session log here — the\n * request\'s history derives from the log right after this settles.\n * @param agent - the agent about to send a request.\n * @param turn - the open turn number.\n * @param step - the step number about to open.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
summary: 'Awaited serial checkpoint before EVERY request of a turn is built (the first as well as each post-tools continuation).',
},
{
name: 'agent/stopping',
mode: 'serial',
signature: '\'agent/stopping\'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void',
jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).',
},
{
name: 'approval/request',
@@ -1181,7 +1153,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'Agent',
declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n}',
declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise<void>;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n abstract retry(): void;\n}',
},
{
name: 'AgentCancelCause',
@@ -1205,7 +1177,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AgentStatus',
declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';',
declaration: 'export type AgentStatus = \'idle\' | \'running\';',
},
{
name: 'AliasSendOptions',
@@ -1521,7 +1493,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n}',
},
{
name: 'InvariantFailure',
@@ -1613,7 +1585,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptMessageData',
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n}',
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}',
},
{
name: 'PromptMessageEnvelope',
@@ -1621,7 +1593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptPrefixContext',
declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n}',
declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n}',
},
{
name: 'PromptSection',
@@ -1753,7 +1725,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SendOptions',
declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n}',
declaration: 'export interface SendOptions {\n target?: SendTarget;\n wakeup?: boolean;\n source?: MessageSource;\n contexts?: HookContext[];\n}',
},
{
name: 'SendTarget',
@@ -2133,7 +2105,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionFailure',
declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
declaration: 'export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n readonly concludesTurn?: never;\n}',
},
{
name: 'ToolExecutionInput',
@@ -2149,7 +2121,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionSuccess',
declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n}',
declaration: 'export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n readonly concludesTurn?: true;\n}',
},
{
name: 'ToolExecutionToken',
@@ -2189,7 +2161,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolRunContext',
declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n}',
declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n concludeTurn(): void;\n}',
},
{
name: 'ToolSchema',
@@ -2261,7 +2233,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'TurnTriggerMap',
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n retry: {\n kind: \'retry\';\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
},
{
name: 'UserInteractionProvider',

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 () => {

View File

@@ -33,7 +33,6 @@ function appendInjection(session: Session, content: ContentBlock[], options?: Al
session.append('user/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}

View File

@@ -32,6 +32,7 @@ const changeSource = {
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
change,
} as const
function view(roundsStarted: number): GoalView {
@@ -43,7 +44,6 @@ function appendChange(session: Session): void {
session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}
@@ -129,7 +129,6 @@ describe('goal-session prompt invariants', () => {
session.append('user/message', {
content: [{ type: 'text', text: 'counterfeit goal state' }],
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
appendRound(session, 2)

View File

@@ -19,13 +19,13 @@ Event-sourced same-session goal state. The service retains one current completio
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The model-visible `user/message` content and its typed `{ kind: 'goal', change }` source must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation.
The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal metadata, source or model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal source changes, model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log.
## Extension points
@@ -53,4 +53,4 @@ Append-only within an epoch: each mutation follows the reusable request prefix a
- **Round-count budget only** — `maxGoalRounds` does not meter tokens, currency, wall time, or provider quotas.
- **No independent evaluator** — the caller that records completion or blocking is authoritative; evaluator-backed certification is deferred to a separate policy layer.
- **One current goal** — parallel objectives and a separate goal database are intentionally absent; history remains available in the session log after replacement or clear.
- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal metadata. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation.
- **Trusted in-process producers** — a plugin with direct `Session` access can append counterfeit goal source data. Strict replay detects malformed or inconsistent records and leaves goal access failed at that record until the log is repaired; this is integrity detection, not plugin isolation.

View File

@@ -132,10 +132,10 @@ function decodeRef(value: unknown): GoalRef {
}
/**
* Decode metadata that declares itself as a goal change. Unrelated metadata
* returns `undefined`; malformed goal metadata fails replay loudly.
* @param value - context-message metadata.
* @returns validated goal change or `undefined` for another metadata kind.
* Decode a value that declares itself as a goal change. Unrelated values
* return `undefined`; malformed goal changes fail replay loudly.
* @param value - candidate source change.
* @returns validated goal change or `undefined` for another value kind.
*/
export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
@@ -311,19 +311,27 @@ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): v
/**
* Decode and verify one model-visible goal state change without folding it. A
* goal state change is a round-zero goal-sourced `user/message` carrying
* `goal/change` metadata; any other user message returns `undefined`. Goal
* metadata on a non-goal source, or a mismatched attribution or rendered body,
* goal state change is a round-zero goal-sourced `user/message` carrying the
* complete change in its source; any other user message returns `undefined`.
* A mismatched attribution, source change, or rendered body
* fails replay loudly.
* @param event - user message whose metadata and rendered content must agree.
* @param event - user message whose source and rendered content must agree.
* @returns validated change, or `undefined` when the message is not a goal state change.
*/
export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undefined {
const change = decodeGoalChange(event.data.meta)
if (change === undefined) return undefined
const source = goalSource(event.data.source)
if (source === undefined) {
const [block] = event.data.content
if (block?.type === 'text' && block.text.startsWith('<goal_state>')) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
}
return undefined
}
if (source.round !== 0) return undefined
const change = decodeGoalChange(source.change)
if (change === undefined) throw new Error(`goal change at session event ${event.seq} lacks source change data`)
const ref = goalChangeRef(change)
if (source === undefined || source.goalId !== ref.id || source.revision !== ref.revision || source.round !== 0) {
if (source.goalId !== ref.id || source.revision !== ref.revision) {
throw new Error(`goal change at session event ${event.seq} has mismatched source attribution`)
}
if (JSON.stringify(event.data.content) !== JSON.stringify(renderGoalChange(change))) {
@@ -340,7 +348,7 @@ export function decodeGoalEvent(event: UserMessageEvent): GoalChangeMeta | undef
*/
export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalChangeMeta | undefined {
if (event.type === 'user/message') {
// A goal state change carries `goal/change` metadata (round zero).
// A goal state change carries a complete source change (round zero).
const change = decodeGoalEvent(event)
if (change !== undefined) {
applyGoalChange(state, change)
@@ -348,10 +356,10 @@ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): GoalC
}
const source = goalSource(event.data.source)
if (source === undefined) return undefined
// A goal-sourced message without change metadata must be a positive-round
// admitted continuation prompt; round zero owes durable change metadata.
// A goal-sourced message without a change must be a positive-round
// admitted continuation prompt; round zero owes a durable source change.
if (source.round === 0) {
throw new Error(`goal source at session event ${event.seq} lacks goal change metadata`)
throw new Error(`goal source at session event ${event.seq} lacks goal change data`)
}
const current = state.goal
if (current === undefined || current.phase !== 'active' || source.goalId !== current.id

View File

@@ -9,8 +9,7 @@ import { Context, Service } from 'cordis'
import z from 'schemastery'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import {
applyGoalChange,
applyGoalEvent,
@@ -488,18 +487,11 @@ export class GoalService extends Service {
/** Accept one mutation into the agent log/FIFO, cache, and live event stream. */
private commit(agent: Agent, cache: GoalCache, change: GoalChangeMeta, activation: GoalActivation): void {
const ref = goalChangeRef(change)
// snapshotJsonValue preserves its input type for callers that already have
// a JsonValue; this interface is structurally JSON but intentionally has no
// index signature, so narrow the validated output at this boundary.
const meta = snapshotJsonValue(change) as JsonValue | undefined
/* v8 ignore next -- validated goal changes contain only finite JSON primitives and records */
if (meta === undefined) throw new Error('goal change is not losslessly JSON-serializable')
const pending: PendingGoalChange = { change, activation, applied: false }
cache.pending.push(pending)
try {
agent.inject(renderGoalChange(change), {
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0 },
meta,
source: { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change },
})
} catch (error: unknown) {
const index = cache.pending.indexOf(pending)

View File

@@ -5,7 +5,7 @@ import type { GoalChangeMeta } from './types.ts'
/**
* Render a complete goal snapshot or clear tombstone without hidden prose.
* @param change - durable goal change metadata.
* @param change - durable goal change carried by the message source.
* @returns the single context block logged and projected verbatim for model reconstruction.
*/
export function renderGoalChange(change: GoalChangeMeta): ContentBlock[] {

View File

@@ -3,7 +3,7 @@
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
/** Version of the goal change metadata embedded in a round-zero `user/message`. */
/** Version of the goal change embedded in a round-zero message source. */
export const GOAL_CHANGE_VERSION = 1
/**

View File

@@ -59,7 +59,7 @@ export interface GoalView extends GoalSnapshot {
readonly activation: GoalActivation
}
/** Goal state-changing verbs recorded in the durable change metadata. */
/** Goal state-changing verbs recorded in the durable source change. */
export type GoalOperation =
| 'create'
| 'edit'
@@ -89,7 +89,7 @@ export interface GoalClearChangeMeta {
readonly clearedAt: number
}
/** Durable metadata union carried by a goal-owned round-zero `user/message`. */
/** Durable change union carried by a goal-owned round-zero message source. */
export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
/** Message attribution for durable goal state and continuation rounds. */
@@ -99,6 +99,8 @@ export interface GoalMessageSource {
readonly revision: number
/** Zero for state changes; positive for admitted continuation rounds. */
readonly round: number
/** Complete durable mutation carried only by round-zero state-change messages. */
readonly change?: GoalChangeMeta
}
declare module '@deepseek-ai/dsh-llm' {

View File

@@ -55,7 +55,9 @@ describe('goal domain through a real cordis.yml and headless process', () => {
expect(contexts).toHaveLength(1)
const context = contexts[0]
if (context?.type !== 'user/message') throw new Error('expected goal context event')
const change = decodeGoalChange(context.data.meta)
const change = context.data.source.kind === 'goal'
? decodeGoalChange(context.data.source.change)
: undefined
if (change === undefined) throw new Error('expected durable goal change')
expect(change).toMatchObject({
operation: 'create',

View File

@@ -38,7 +38,6 @@ function appendInjection(session: Session, content: ContentBlock[], options?: Al
const context = {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}
const last = session.events.at(-1)
const open = last !== undefined && last.type !== 'turn/end'
@@ -137,8 +136,8 @@ describe('GoalService creation and replay', () => {
const context = session.events[1]
expect(context?.type).toBe('user/message')
if (context?.type !== 'user/message') throw new Error('expected goal context')
expect(context.data.source).toEqual({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = decodeGoalChange(context.data.meta)
expect(context.data.source).toMatchObject({ kind: 'goal', goalId: goal.id, revision: 1, round: 0 })
const change = context.data.source.kind === 'goal' ? decodeGoalChange(context.data.source.change) : undefined
if (change === undefined) throw new Error('expected decoded goal change')
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
expect(context.data.content).toEqual(renderGoalChange(change))
@@ -412,7 +411,9 @@ describe('GoalService mutations', () => {
ctx.goals.clear(agent, goal)
const clear = session.events
.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal')
.map(event => event.type === 'user/message' ? decodeGoalChange(event.data.meta) : undefined)
.map(event => event.type === 'user/message' && event.data.source.kind === 'goal'
? decodeGoalChange(event.data.source.change)
: undefined)
.at(-1)
expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 })
expect(() => foldGoal(session.events)).not.toThrow()
@@ -518,11 +519,11 @@ describe('GoalService mutations', () => {
createdAt: 12,
updatedAt: 12,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 } as const
const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('user/message', {
content: renderGoalChange(change), source, meta: change as never,
content: renderGoalChange(change), source,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
@@ -552,12 +553,13 @@ describe('GoalService mutations', () => {
updatedAt: 12,
}
appendInjection(session, renderGoalChange(change), {
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0 },
meta: change as never,
source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 0, change },
})
appendInjection(session, [{ type: 'text', text: 'corrupt' }], {
source: { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 },
meta: { ...change, operation: 'edit', extra: true } as never,
source: {
kind: 'goal', goalId: change.goal.id, revision: 2, round: 0,
change: { ...change, operation: 'edit', extra: true } as never,
},
})
expect(() => ctx.goals.get(agent)).toThrow('invalid shape')
@@ -595,13 +597,13 @@ describe('goal replay validation', () => {
goalId: change.operation === 'clear' ? change.cleared.id : change.goal.id,
revision: change.operation === 'clear' ? change.cleared.revision : change.goal.revision,
round: 0,
change,
}
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('user/message', {
content: overrides.content ?? renderGoalChange(change),
source,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
@@ -649,7 +651,6 @@ describe('goal replay validation', () => {
const session = new Session(SessionId('unrelated'))
appendInjection(session, [{ type: 'text', text: 'other' }], {
source: { kind: 'plugin', plugin: 'test' },
meta: { kind: 'other' },
})
expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 })
const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const
@@ -799,7 +800,7 @@ describe('goal replay validation', () => {
content: [{ type: 'text', text: 'missing' }], source,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(() => foldGoal(session.events)).toThrow('lacks goal change metadata')
expect(() => foldGoal(session.events)).toThrow('lacks source change data')
})
it('rejects malformed snapshots, refs, counters, and timestamps', () => {
@@ -854,11 +855,11 @@ describe('goal replay validation', () => {
cleared: { id: change.goal.id, revision: 2 },
clearedAt: 20,
}
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0 } as const
const source = { kind: 'goal', goalId: change.goal.id, revision: 2, round: 0, change: clear } as const
const turn = nextTurn(session)
session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
session.append('user/message', {
content: renderGoalChange(clear), source, meta: clear as never,
content: renderGoalChange(clear), source,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
expect(foldGoal(session.events)).toEqual({

View File

@@ -30,6 +30,7 @@ const changeSource = {
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
change,
} as const
async function setup(): Promise<Context> {
@@ -48,7 +49,6 @@ describe('goal stream invariants', () => {
session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', {
@@ -74,7 +74,6 @@ describe('goal stream invariants', () => {
session.append('user/message', {
content: [{ type: 'text', text: 'counterfeit' }],
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
@@ -85,7 +84,6 @@ describe('goal stream invariants', () => {
session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
}).not.toThrow()
})
@@ -98,7 +96,6 @@ describe('goal stream invariants', () => {
session.append('user/message', {
content: renderGoalChange(change),
source: changeSource,
meta: change as never,
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })

View File

@@ -39,7 +39,6 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
session.append('user/message', {
content,
source,
...options?.meta === undefined ? {} : { meta: options.meta },
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},

View File

@@ -515,7 +515,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
additionalContexts: [{
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -533,7 +532,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream canonical value replacement', async () => {
@@ -566,7 +564,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -578,7 +575,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {

View File

@@ -127,7 +127,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
additionalContexts: [{
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -141,7 +140,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
})
@@ -171,7 +169,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
meta: { owner: 'policy' },
}],
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -182,7 +179,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'user/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {

View File

@@ -1534,9 +1534,9 @@ function activeSurfaceSeqs(session: Session): Set<number> {
return new Set(session.surface.nodes)
}
function sessionReferenceCard(meta: unknown): string[] | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
const record = meta as Record<string, unknown>
function sessionReferenceCard(source: unknown): string[] | undefined {
if (typeof source !== 'object' || source === null) return undefined
const record = source as Record<string, unknown>
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
const references = record['references'] as unknown[]
const labels: string[] = []
@@ -1553,7 +1553,7 @@ function sessionReferenceCard(meta: unknown): string[] | undefined {
function promptReferenceCards(event: Extract<SessionEvent, { type: 'user/message' | 'steering/message' }>): string[][] {
return event.data.envelope?.prefixContexts.flatMap((context) => {
const card = sessionReferenceCard(context.meta)
const card = sessionReferenceCard(context.source)
return card === undefined ? [] : [card]
}) ?? []
}
@@ -1956,7 +1956,7 @@ export function createTuiChat(
// boolean avoids narrowing `source`, so the label keeps its full union.
const source = event.data.source
if (source.kind !== 'user') {
const references = sessionReferenceCard(event.data.meta)
const references = sessionReferenceCard(event.data.source)
if (references !== undefined) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))

View File

@@ -117,11 +117,10 @@ describe('TUI session-reference snapshot', () => {
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
displayContent: [{ type: 'text', text: 'Use @Source session' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
source: {
kind: 'session-reference',
references: [{ sessionId: 'source-session', compacted: true }],
},
} as never,
}],
})
expect(user?.type === 'user/message' && user.data.content[1]).toEqual({

View File

@@ -1067,8 +1067,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: { kind: 'session-reference', references: [{ sessionId: 'source-session' }] },
source: { kind: 'session-reference', references: [{ sessionId: 'source-session' }] },
}])
result.agent.status = 'running'
@@ -1324,11 +1323,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
envelope: {
displayContent: [{ type: 'text', text: 'visible referenced question' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
source: {
kind: 'session-reference',
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
},
} as never,
}],
},
}, { surfaceOp: 'append' })
@@ -1348,13 +1346,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
envelope: {
displayContent: [{ type: 'text', text: 'visible steering prompt' }],
prefixContexts: [
{ source: { kind: 'plugin', plugin: 'other' }, meta: { kind: 'other' } },
{ source: { kind: 'plugin', plugin: 'other' } },
{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
source: {
kind: 'session-reference',
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
},
} as never,
},
],
},
@@ -1366,12 +1363,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session.append('user/message', {
content: [{ type: 'text', text: 'secret full snapshot payload' }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
source: {
kind: 'session-reference',
version: 1,
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
},
} as never,
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
@@ -1382,17 +1378,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
[{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
]
for (const [meta, text] of invalidCards) {
for (const [source, text] of invalidCards) {
result.session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta,
source: source as never,
}, { surfaceOp: 'append' })
}
result.session.append('user/message', {
content: [{ type: 'text', text: 'same-label snapshot' }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] },
source: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] } as never,
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · same')