refactor(agent): unify sourced message delivery

This commit is contained in:
_Kerman
2026-07-24 22:38:50 +08:00
parent 009d113e0e
commit 992cf894af
197 changed files with 1890 additions and 2132 deletions

View File

@@ -14,7 +14,6 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import type {
AgentMessage,
Agent,
AliasSendOptions,
CancelOptions,
AgentInterruptReason,
AgentOptions,
@@ -27,9 +26,7 @@ import type {
import {
BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -89,10 +86,11 @@ export class ReactLoopAgent implements Agent {
/** Accept and route one unified send item. */
send(
content: ContentBlock[],
input: UserMessageData,
options: SendOptions,
): AgentMessageId {
const { target, wakeup, source } = options
const { content, source } = input
const { target, wakeup } = options
const id = AgentMessageId(randomUUID())
if (target === 'next-step' && !wakeup) {
if (this.turnOpen) {
@@ -120,29 +118,26 @@ export class ReactLoopAgent implements Agent {
}
/** Queue one ordinary prompt turn and wake the driver. */
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, {
followup(input: UserMessageData): AgentMessageId {
return this.send(input, {
target: 'next-turn',
wakeup: true,
source: options?.source ?? { kind: 'user' },
})
}
/** Steer the open turn, falling back to a waking prompt while idle. */
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, {
steer(input: UserMessageData): AgentMessageId {
return this.send(input, {
target: 'next-step',
wakeup: true,
source: options?.source ?? { kind: 'user' },
})
}
/** Append model-facing context without waking the driver. */
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, {
inject(input: UserMessageData): AgentMessageId {
return this.send(input, {
target: 'next-step',
wakeup: false,
source: options?.source ?? { kind: 'plugin', plugin: '' },
})
}

View File

@@ -11,8 +11,7 @@
import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { AdditionalContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Session, UserMessageData } from '@deepseek-ai/dsh-session'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
/** One tool call after argument parsing, ready to schedule. */
@@ -58,7 +57,7 @@ export async function executeToolCalls(
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
acceptContext: (context: AdditionalContext) => void,
acceptContext: (context: UserMessageData) => void,
): Promise<{ concluded: boolean }> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
@@ -120,7 +119,7 @@ async function runGroup(
group: PlannedCall[],
mode: ToolExecutionMode['kind'],
signal: AbortSignal,
acceptContext: (context: AdditionalContext) => void,
acceptContext: (context: UserMessageData) => void,
): Promise<GroupOutcome> {
const { session } = ctx.agents.requireInitiator()
const { maxParallelToolCalls } = ctx.agentLoop.config

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string): void {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Adapter that holds both drivers at the same awaited continuation. */

View File

@@ -21,7 +21,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
}
function send(agent: Agent, text: string): void {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('Agent', () => {
@@ -32,7 +32,7 @@ describe('Agent', () => {
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'p' } })
agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.status).toBe('idle')
@@ -41,11 +41,11 @@ describe('Agent', () => {
expect(flushes).toBe(0)
})
it('inject() defaults its source to an empty plugin, never user', async () => {
it('inject() preserves an explicitly empty plugin source', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'no explicit source' }])
agent.inject({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })
const injected = agent.session.events.at(-1)
expect(injected?.type === 'user/message' && injected.data.source)
@@ -57,10 +57,7 @@ describe('Agent', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
expect(() => {
agent.inject(
[{ type: 'text', text: 'x', bad: 1n } as never],
{ source: { kind: 'plugin', plugin: 'p' } },
)
agent.inject({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
})
@@ -70,10 +67,7 @@ describe('Agent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.steer(
[{ type: 'text', text: 'steer idle' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)
agent.steer({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })
await agent.whenIdle()
expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)

View File

@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Resolve on the agent's next idle transition (event-based, not status poll). */
@@ -63,7 +63,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject !== agent) return
seen.push(`first:${cause.kind}`)
subject.followup([{ type: 'text', text: 'queued by cancel observer' }])
subject.followup({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } })
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, cause) => {
@@ -106,11 +106,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
agent.send([{ type: 'text', text: 'preserved' }], {
target: 'next-turn',
wakeup: false,
source: { kind: 'user' },
})
agent.send({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
// keepInbox cancel: no active turn, work preserved, no discard event.
agent.cancel({ kind: 'user' }, { keepInbox: true })
expect(discards).toEqual([])
@@ -128,11 +124,7 @@ describe('Agent.cancel()', () => {
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
// resolves (the agent is quiescent), leaving the item queued.
agent.send([{ type: 'text', text: 'quiet' }], {
target: 'next-turn',
wakeup: false,
source: { kind: 'user' },
})
agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
@@ -148,11 +140,7 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'quiet' }], {
target: 'next-turn',
wakeup: false,
source: { kind: 'user' },
})
agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
const idle = agent.whenIdle()
// Cancel reaches quiescence with no status transition and no waking send;
// whenIdle must still resolve (previously it hung until the next send).
@@ -587,7 +575,7 @@ describe('Agent.cancel()', () => {
expect(agent.status).toBe('running')
// Steer (joins the running turn's steering FIFO), then cancel: the steering
// must be dropped, NOT re-enqueued as a new queued turn.
agent.steer([{ type: 'text', text: 'steer text' }])
agent.steer({ content: [{ type: 'text', text: 'steer text' }], source: { kind: 'user' } })
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)

View File

@@ -98,7 +98,7 @@ describe('config-driven session id', () => {
first = ctx.agents.get(SessionId('config-exact-reload'))
}
expect(first).toBeDefined()
first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
first!.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })
await waitForIdle(ctx, first!)
await firstLoop.dispose()
@@ -110,7 +110,7 @@ describe('config-driven session id', () => {
}
expect(second).toBeDefined()
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
second!.followup({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })
await waitForIdle(ctx, second!)
await ctx.sessions.flush(second!.session)
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
@@ -137,9 +137,7 @@ describe('config-driven session id', () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before replacement' }], {
source: { kind: 'plugin', plugin: 'test' },
})
first.inject({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } })
await ctx.sessions.flush(first.session)
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
.toContain('persist before replacement')
@@ -183,9 +181,7 @@ describe('config-driven session id', () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
source: { kind: 'plugin', plugin: 'test' },
})
first.inject({ content: [{ type: 'text', text: 'persist before cancellation' }], source: { kind: 'plugin', plugin: 'test' } })
await ctx.sessions.flush(first.session)
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
.toContain('persist before cancellation')
@@ -349,7 +345,7 @@ describe('config-driven session id', () => {
expect(a1.id).toBe(a1.session.id)
expect(a1.session.id).toMatch(idPattern)
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -368,7 +364,7 @@ describe('config-driven session id', () => {
expect(a2.id).toBe(a2.session.id)
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
a2.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })
await waitForIdle(ctx2, a2)
await ctx2.fiber.dispose()
})
@@ -389,7 +385,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()

View File

@@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('assistant replay provenance', () => {
@@ -85,7 +85,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute() {
agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'plugin', plugin: 'test' } })
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
@@ -185,7 +185,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute(_args, exec) {
agent.inject([{ type: 'text', text: 'accepted before disposal' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'accepted before disposal' }], source: { kind: 'plugin', plugin: 'test' } })
started.resolve(undefined)
const signal = exec.signal
if (!signal) throw new Error('tool execution signal is missing')
@@ -254,7 +254,7 @@ describe('abort during tool execution ends the turn', () => {
await waitForIdle(ctx, agent)
ctx.on('agent/step', (subject, turn) => {
if (subject === agent && turn === 2) {
agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } })
}
})
send(agent, 'start a text-only turn')
@@ -282,7 +282,7 @@ describe('steering from late extension points is never stranded', () => {
ctx.on('agent/stopping', () => {
if (!steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'one more thing' }])
agent.steer({ content: [{ type: 'text', text: 'one more thing' }], source: { kind: 'user' } })
}
})
@@ -307,7 +307,7 @@ describe('steering from late extension points is never stranded', () => {
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
steeredOnce = true
agent.steer([{ type: 'text', text: 'goal reminder from step/end' }])
agent.steer({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } })
})
send(agent, 'go')
@@ -338,7 +338,7 @@ describe('steering from late extension points is never stranded', () => {
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && !steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'too late for this turn' }])
agent.steer({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } })
}
})
@@ -493,7 +493,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
description: '',
parameters: {},
async execute() {
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } })
agent.steer({ content: [{ type: 'text', text: 's' }], source: { kind: 'plugin', plugin: 'goal' } })
return []
},
}))
@@ -550,7 +550,7 @@ describe('turn numbering continues across seeded sessions', () => {
const turns: number[] = []
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
forked.followup([{ type: 'text', text: 'continue' }])
forked.followup({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })
await new Promise<void>((resolve) => {
ctx2.on('agent/status', (subject, status) => {
if (subject === forked && status === 'idle') resolve()

View File

@@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('tool JSON parse', () => {

View File

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
function events(agent: Agent): SessionEvent[] {
@@ -151,7 +151,7 @@ describe('agent/prompt-submit', () => {
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
agent.followup([{ type: 'text', text: 'do something' }])
agent.followup({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })
await agent.whenIdle()
// the model was never called
@@ -252,7 +252,7 @@ describe('agent/session-start', () => {
const ctx = await harness(adapter)
ctx.on('agent/session-start', (agent) => {
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -396,10 +396,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction.
ctx.on('agent/session-start', (agent, source) => {
agent.inject(
[{ type: 'text', text: `policy active (started: ${source})` }],
{ source: { kind: 'plugin', plugin: 'native-guard' } },
)
agent.inject({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {

View File

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('agent loop', () => {
@@ -271,7 +271,7 @@ describe('agent loop', () => {
parameters: {},
async execute() {
// steer while the turn is running (during tool execution)
agent.steer([{ type: 'text', text: 'change of plans' }])
agent.steer({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } })
return [{ type: 'text', text: 'tool done' }]
},
}))
@@ -299,8 +299,8 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.steer([{ type: 'text', text: 'first idle steer' }])
agent.steer([{ type: 'text', text: 'second idle steer' }])
agent.steer({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })
agent.steer({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })
await idle
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
@@ -321,7 +321,7 @@ describe('agent loop', () => {
ctx.on('agent/step', (subject) => {
if (subject !== agent || !fail) return
fail = false
subject.steer([{ type: 'text', text: 'pending steering' }])
subject.steer({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })
throw new Error('step failed')
})
@@ -347,7 +347,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
agent.inject({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
@@ -369,9 +369,7 @@ describe('agent loop', () => {
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>'
agent.inject([{ type: 'text', text }], {
source: { kind: 'plugin', plugin: 'workspace-context' },
})
agent.inject({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'workspace-context' } })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -398,11 +396,9 @@ describe('agent loop', () => {
async execute() {
await Promise.resolve()
const first = { type: 'text' as const, text: 'mid-turn notice' }
agent.inject([first], {
source: { kind: 'plugin', plugin: 'x' },
})
agent.inject({ content: [first], source: { kind: 'plugin', plugin: 'x' } })
first.text = 'mutated after inject'
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
agent.inject({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'plugin', plugin: 'x' } })
visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
return [{ type: 'text', text: 'ok' }]
},
@@ -455,9 +451,7 @@ describe('agent loop', () => {
parameters: {},
async execute() {
expect(() => {
agent.inject([{ type: 'text', text: 'invalid' }], {
source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never,
})
agent.inject({ content: [{ type: 'text', text: 'invalid' }], source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never })
}).toThrow('agent context must be losslessly JSON-serializable')
return [{ type: 'text', text: 'rejected invalid context' }]
},
@@ -482,9 +476,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/stopping', (subject) => {
if (steps < 3) {
subject.steer([{ type: 'text', text: 'continue' }], {
source: { kind: 'plugin', plugin: 'loop-test' },
})
subject.steer({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })
}
})
@@ -692,9 +684,7 @@ describe('agent loop', () => {
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/stopping', (subject) => {
if (steps < 2) {
subject.steer([{ type: 'text', text: 'continue after truncation' }], {
source: { kind: 'plugin', plugin: 'max-tokens-test' },
})
subject.steer({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })
}
})
@@ -920,12 +910,9 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'user message' }])
agent.followup({ content: [{ type: 'text', text: 'user message' }], source: { kind: 'user' } })
await Promise.resolve()
agent.followup(
[{ type: 'text', text: 'plugin message' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)
agent.followup({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })
await idle
const triggers = agent.session.events

View File

@@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => {
const { seen: trace } = recordStatus(ctx, agent)
const idle = nextIdle(ctx, agent)
// Send all in one synchronous tick: they queue before the loop wakes.
for (const text of texts) agent.followup([{ type: 'text', text }])
for (const text of texts) agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
await idle
// No message lost: every send appears as a user/message, in order.
@@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => {
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
for (const text of texts) {
const idle = nextIdle(ctx, agent)
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
await idle
}
// Each send was drained at a separate turn start: N turns, 1..N.
@@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => {
for (const step of steps) {
const idle = nextIdle(ctx, agent)
lastIdle = idle
agent.followup([{ type: 'text', text: step.text }])
agent.followup({ content: [{ type: 'text', text: step.text }], source: { kind: 'user' } })
if (step.settle) await idle
}
await lastIdle

View File

@@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
// Turn 1: forces a tool call → at least two steps (two model requests).
agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
agent.followup({ content: [{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
// Turn 2: a follow-up over the same (longer) prefix.
agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
agent.followup({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const usages = [...agent.session.events]

View File

@@ -40,7 +40,7 @@ describe('agent/request-error', () => {
recoveries += 1
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(recoveries).toBe(0)
@@ -75,7 +75,7 @@ describe('agent/request-error', () => {
subject.retry()
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(seen.map(item => ({
@@ -113,7 +113,7 @@ describe('agent/request-error', () => {
subject.cancel({ kind: 'user' })
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
@@ -136,7 +136,7 @@ describe('agent/request-error', () => {
throw new Error('recovery failed')
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Assert `previous` is a strict value-prefix of `current`. */
@@ -170,7 +170,7 @@ describe('request stability across the loop', () => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
if (!injected) {
injected = true
agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })
}
return next()
})

View File

@@ -146,7 +146,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -174,7 +174,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
expect(sources1).toEqual(['startup'])
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -475,9 +475,9 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
a1.inject({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } })
await a1.whenIdle()
await ctx1.fiber.dispose()
@@ -503,7 +503,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
const seqs1 = events1.map(e => e.seq)
@@ -530,7 +530,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
// …and a new turn continues numbering (turn 2) with contiguous seqs.
a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
a2.followup({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } })
await waitForIdle(ctx2, a2)
const allSeqs = a2.session.events.map(e => e.seq)
expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates

View File

@@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => {
if (event.type === 'user/message') heard.push('a-sees:user-message')
})
b.followup(text('for b'))
b.followup({ content: text('for b'), source: { kind: 'user' } })
await waitForIdle(ctx, b)
expect(heard).toEqual([]) // nothing of b's leaked into a's scope
a.followup(text('for a'))
a.followup({ content: text('for a'), source: { kind: 'user' } })
await waitForIdle(ctx, a)
expect(heard).toContain('a-sees:a:running')
expect(heard).toContain('a-sees:user-message')
@@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => {
if (event.type === 'turn/start') { off(); resolve() }
})
})
agent.followup(text('work'))
agent.followup({ content: text('work'), source: { kind: 'user' } })
await turnOpen
await owner.dispose()
expect(order).toEqual([

View File

@@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
gated.release('1'); gated.release('2'); gated.release('3')
@@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
@@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => replacement.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual(['1'])
@@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => initial.started.length === 2)
initial.release('1')
await until(() => events(agent).some(event =>
@@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
gated.release('2')
await new Promise(r => setTimeout(r, 5))
@@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -295,7 +295,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1', '2'])
@@ -324,7 +324,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
@@ -350,7 +350,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
@@ -377,7 +377,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 3)
gated.release('3'); gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -398,7 +398,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -436,7 +436,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 1)
gated.release('1')
await waitForIdle(ctx, agent)
@@ -466,7 +466,7 @@ describe('tool-call scheduler: abort handling', () => {
}
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
@@ -498,7 +498,7 @@ describe('tool-call scheduler: abort handling', () => {
return next()
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
@@ -528,7 +528,7 @@ describe('tool-call scheduler: abort handling', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' })
gated.release('1')
@@ -575,7 +575,7 @@ describe('tool-call scheduler: abort handling', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' })
gated.release('1')

View File

@@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
const ctx = await harness(adapter, toolOrder)
for (const name of registrationOrder) registerNamed(ctx, name)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
return { ctx, agent, adapter }
}
@@ -102,7 +102,7 @@ describe('loop-level canonical tool order', () => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])

View File

@@ -56,10 +56,10 @@ Turn and step boundaries and the model token stream are durable `session/event`
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. `SendOptions` requires `target`, `wakeup`, and `source`; callers wanting the ordinary user-message preset use `followup(content)`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.followup(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 a turn is open, stage steering for its next safe boundary without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Cancellation or disposal may discard pending steering.
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by `source`. While a turn is open, injection waits in the outbox for the next safe boundary. While idle, it appends immediately without opening a turn; persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
- `agent.send(input, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `input` is the existing `UserMessageData { content, source }`, while `SendOptions` requires only the routing policy `target` and `wakeup`. 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'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(input)` — the `next-step`/wakeup preset: while a turn is open, stage steering for its next safe boundary without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Cancellation or disposal may discard pending steering.
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. While a turn is open, injection waits in the outbox for the next safe boundary. While idle, it appends immediately without opening a turn; persistence reacts to `session/event` independently. Injection 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`
@@ -78,7 +78,7 @@ The handle every plugin programs against:
#### What the model sees
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/step`, and other declared events let plugins block a prompt or add durable request material; this interface contributes no fixed prose itself.
#### Token effect
@@ -109,5 +109,5 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`AdditionalContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
- **Each additional `UserMessageData` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).

View File

@@ -40,8 +40,7 @@ export type SendTarget = 'next-turn' | 'next-step'
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
* {@link Agent.inject} (`next-step`/no-wakeup).
*
* The object is complete so routing and provenance are explicit; callers that
* want the ordinary user-message preset use {@link Agent.followup}.
* The object is complete so routing policy is explicit.
*/
export interface SendOptions {
/** Queue the item joins. */
@@ -54,14 +53,6 @@ export interface SendOptions {
* (the injection preset).
*/
wakeup: boolean
/** Producer provenance; direct human input uses `{ kind: 'user' }`. */
source: MessageSource
}
/** Options accepted by the fixed-preset aliases, which own `target` and `wakeup`. */
export interface AliasSendOptions {
/** Producer provenance; each alias supplies its documented default when omitted. */
source?: MessageSource
}
/**
@@ -82,8 +73,8 @@ export function AgentMessageId(id: string): AgentMessageId {
/**
* One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live
* events. `id` is the value `send` returned to the caller, stable across this
* message's enqueue, dequeue, and discard events. Source defaults are already
* applied, so these are the exact values the item was accepted with.
* message's enqueue, dequeue, and discard events. Its content and source are
* the exact input values accepted by the agent.
*/
export interface AgentMessage extends UserMessageData {
/** The id `send` returned for this message. */
@@ -108,9 +99,6 @@ export interface CancelOptions {
*/
export type AgentStatus = 'idle' | 'running'
/** Additional model-facing context produced beside a prompt or tool result. */
export type AdditionalContext = UserMessageData
/**
* Prompt interception result. `allow.content` replaces the prompt, while
* `additionalContexts` appends model-facing context before the turn starts.
@@ -118,7 +106,7 @@ export type AdditionalContext = UserMessageData
* `next()` preserves both fields unless it intentionally replaces them.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] }
| { kind: 'block'; reason: string }
/** Model-request failure with an optional machine-routable provider code. */
@@ -171,11 +159,11 @@ export interface Agent {
* without running the model: an open turn stages it for the next safe log
* position, while an idle injection appends it immediately without opening
* a turn.
* @param content - the model-facing content blocks to deliver.
* @param options - target queue, wakeup decision, and source.
* @param input - model-facing content and its producer provenance.
* @param options - target queue and wakeup decision.
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
*/
send(content: ContentBlock[], options: SendOptions): AgentMessageId
send(input: UserMessageData, options: SendOptions): AgentMessageId
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
@@ -195,11 +183,10 @@ export interface Agent {
* Queue an ordinary follow-up turn and wake the driver — the
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
* ordinary message of its own turn.
* @param content - the prompt content blocks.
* @param options - message source.
* @param input - prompt content and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
followup(input: UserMessageData): AgentMessageId
/**
* Submit steering into the running turn — the `next-step`/wakeup preset of
@@ -208,23 +195,20 @@ export interface Agent {
* remainder stays staged without waking the agent; retry or a later prompt
* takes it. Idle steering falls back to a woken follow-up turn, while
* cancellation or disposal may discard pending steering.
* @param content - the steering content blocks.
* @param options - message source.
* @param input - steering content and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
steer(input: UserMessageData): AgentMessageId
/**
* Append model-facing context without running the model — the
* `next-step`/no-wakeup preset of {@link send}. An open-turn injection stages
* at the next safe log position; an idle injection appends immediately
* without opening a turn. An omitted source defaults to
* `{ kind: 'plugin', plugin: '' }`.
* @param content - the injected context content blocks.
* @param options - context source.
* without opening a turn.
* @param input - injected context and its producer provenance.
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
inject(input: UserMessageData): AgentMessageId
/**
* Re-open a turn on the current session log without a new prompt — the
@@ -325,8 +309,9 @@ declare module 'cordis' {
// ---- the machine's extension seams ----
/**
* Allow, rewrite, or block one claimed prompt before it becomes a user
* message. Call `next()` for the unchanged default. The signal controls only this turn;
* listeners may cooperate with it but must not retain it for another turn.
* message or opens a turn. Call `next()` for the unchanged default. The
* signal controls only this admission attempt; listeners may cooperate with
* it but must not retain it for a later attempt or turn.
* @param agent - the agent whose turn claimed the message.
* @param content - the claimed message's blocks, as queued.
* @param source - the message's resolved source.

View File

@@ -62,7 +62,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
### Request-header reconstruction (`request-header.ts`)
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`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. 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, 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. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.

View File

@@ -42,7 +42,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `AdditionalContext` for the loop's post-result FIFO.
- `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute `UserMessageData` for the loop's post-result FIFO.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.

View File

@@ -10,9 +10,9 @@ import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } fr
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { AdditionalContext, Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, UserMessageData } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
@@ -306,7 +306,7 @@ export interface ToolRunContext extends ToolExecution {
* the agent loop. Contexts retain their individual source and metadata and
* are emitted in call order.
*/
deferContext(context: AdditionalContext): void
deferContext(context: UserMessageData): void
/** Mark a successful final result as terminal for the current agent turn. */
concludeTurn(): void
}
@@ -440,7 +440,7 @@ export interface ToolExecutionSuccess {
readonly content: ContentBlock[]
readonly error?: never
readonly meta?: JsonValue
readonly additionalContexts?: AdditionalContext[]
readonly additionalContexts?: UserMessageData[]
/** The agent loop stops after committing this successful result batch. */
readonly concludesTurn?: true
}
@@ -452,7 +452,7 @@ export interface ToolExecutionFailure {
readonly value?: never
readonly content: ContentBlock[]
readonly meta?: JsonValue
readonly additionalContexts?: AdditionalContext[]
readonly additionalContexts?: UserMessageData[]
readonly concludesTurn?: never
}
@@ -475,9 +475,9 @@ export type PreToolDecision =
* next request, or block by turning corrective feedback into an error result.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: AdditionalContext[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: AdditionalContext[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessageData[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessageData[] }
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessageData[] }
/**
* Best-effort human-readable message from an arbitrary thrown value: Error
@@ -652,7 +652,7 @@ export class ToolRegistry extends Service {
}
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
private deferredContexts = new WeakMap<ToolRunContext, AdditionalContext[]>()
private deferredContexts = new WeakMap<ToolRunContext, UserMessageData[]>()
/** Successful executions whose tool body declared the current turn complete. */
private concludingExecutions = new WeakSet<ToolExecution>()
/** Enclosing transport tokens marked terminal by a successful nested call. */
@@ -969,7 +969,7 @@ export class ToolRegistry extends Service {
}
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
const deferredContexts: AdditionalContext[] = []
const deferredContexts: UserMessageData[] = []
const token = createExecutionToken()
const callId = exec.callId
const name = exec.name
@@ -987,7 +987,7 @@ export class ToolRegistry extends Service {
signal,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
deferContext(context: AdditionalContext): void {
deferContext(context: UserMessageData): void {
deferredContexts.push(context)
},
concludeTurn(): void {