diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bbe0cdb829..c20a820ccb 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -542,7 +542,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', - jsDoc: '/**\n * Snapshot all references before enqueue and return one aggregated durable context.\n * @param agent - target agent; references to it are rejected.\n * @param content - already host-normalized readable message content.\n * @param references - structured source sessions in mention order.\n * @param signal - optional cancellation boundary for host request teardown.\n * @returns detached content and zero or one prepared contexts.\n */', + jsDoc: '/**\n * Snapshot all references before enqueue and return one aggregated durable context.\n * @param agent - target agent; references to it are rejected.\n * @param content - already host-normalized readable message content.\n * @param references - structured source sessions in mention order.\n * @param signal - optional cancellation boundary for host request teardown.\n * @returns detached content and optional referenced-session context.\n */', }, ], }, @@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/inbox/discard', mode: 'emit', signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: AgentMessage[]): void', - jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', }, { @@ -913,7 +913,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise', - 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 */', + jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. 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.', }, { @@ -1151,6 +1151,10 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'AdditionalContext', + declaration: 'export interface AdditionalContext {\n content: ContentBlock[];\n source: MessageSource;\n}', + }, { 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;\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}', @@ -1181,7 +1185,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AliasSendOptions', - declaration: 'export type AliasSendOptions = Omit;', + declaration: 'export interface AliasSendOptions {\n source?: MessageSource;\n}', }, { name: 'ApprovalOutcome', @@ -1491,10 +1495,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GoalView', declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', }, - { - name: 'HookContext', - declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n}', - }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', @@ -1569,7 +1569,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedReferencedMessage', - declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n contexts: HookContext[];\n}', + declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n additionalContext?: AdditionalContext;\n}', }, { name: 'PresetOption', @@ -1585,15 +1585,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptMessageData', - declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}', - }, - { - name: 'PromptMessageEnvelope', - declaration: 'export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n}', - }, - { - name: 'PromptPrefixContext', - declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n}', + declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n}', }, { name: 'PromptSection', @@ -1725,7 +1717,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}', + declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n source: MessageSource;\n}', }, { name: 'SendTarget', @@ -2105,7 +2097,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 readonly concludesTurn?: never;\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?: AdditionalContext[];\n readonly concludesTurn?: never;\n}', }, { name: 'ToolExecutionInput', @@ -2121,7 +2113,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 readonly concludesTurn?: true;\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?: AdditionalContext[];\n readonly concludesTurn?: true;\n}', }, { name: 'ToolExecutionToken', @@ -2161,7 +2153,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolRunContext', - declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n concludeTurn(): void;\n}', + declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: AdditionalContext): void;\n concludeTurn(): void;\n}', }, { name: 'ToolSchema', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 622afd5faa..5aaeb491da 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -127,12 +127,13 @@ export class ReactLoopAgent extends Agent { } /** Accept and route one unified send item. */ - send(content: ContentBlock[], options: SendOptions = {}): AgentMessageIdType { + send( + content: ContentBlock[], + options: SendOptions = { target: 'next-turn', wakeup: true, source: { kind: 'user' } }, + ): AgentMessageIdType { const id = AgentMessageId(randomUUID()) - const target = options.target ?? 'next-turn' - const wakeup = options.wakeup ?? true + const { target, wakeup, source } = options if (target === 'next-step' && !wakeup) { - const source = options.source ?? { kind: 'plugin', plugin: '' } if (this.turnOpen) { this.outbox.push({ content, source }) return id @@ -150,7 +151,7 @@ export class ReactLoopAgent extends Agent { const message: PendingMessage = { id, content, - source: options.source ?? { kind: 'user' }, + source, wakeup, } if (steering) { diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index fcace76767..c60c5e407b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -106,7 +106,11 @@ 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 }) + agent.send([{ type: 'text', text: 'preserved' }], { + target: 'next-turn', + wakeup: false, + source: { kind: 'user' }, + }) // keepInbox cancel: no active turn, work preserved, no discard event. agent.cancel({ kind: 'user' }, { keepInbox: true }) expect(discards).toEqual([]) @@ -124,7 +128,11 @@ 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 }) + agent.send([{ type: 'text', text: 'quiet' }], { + target: 'next-turn', + wakeup: false, + source: { kind: 'user' }, + }) await agent.whenIdle() expect(agent.status).toBe('idle') expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) @@ -140,7 +148,11 @@ 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 }) + agent.send([{ type: 'text', text: 'quiet' }], { + target: 'next-turn', + wakeup: false, + source: { kind: 'user' }, + }) 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). diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 2103dd6831..d144127498 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -98,7 +98,7 @@ describe('config-driven session id', () => { first = ctx.agents.get(SessionId('config-exact-reload')) } expect(first).toBeDefined() - first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + first!.followup([{ 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!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + second!.followup([{ 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')) @@ -335,7 +335,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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -354,7 +354,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.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) + a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) await waitForIdle(ctx2, a2) await ctx2.fiber.dispose() }) @@ -375,7 +375,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.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 5d62355b35..6e0b7df5dd 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -807,7 +807,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { notifiedSource = info.source }) - agent.send(content, { source }) + agent.followup(content, { source }) content[0]!.text = 'caller-mutated-send' source.plugin = 'caller-mutated-source' await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index ebc2287a83..340c9e0d96 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -53,7 +53,11 @@ describe('inbox acceptance', () => { agent.send([{ type: 'text', text: 'first', bad: 1n } as never]) }).toThrow(/losslessly JSON-serializable/) expect(() => { - agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never }) + agent.send([{ type: 'text', text: 'first' }], { + target: 'next-turn', + wakeup: true, + source: { kind: 'plugin', plugin: 'p', bad: 1n } as never, + }) }).toThrow(/losslessly JSON-serializable/) expect(queued).toBe(0) expect(agent.session.events).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 384b259a38..12d3f23b7c 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -125,7 +125,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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -153,7 +153,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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -459,7 +459,7 @@ 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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ 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' } }) await a1.whenIdle() @@ -479,7 +479,7 @@ 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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) + a1.followup([{ 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' } }) await a1.whenIdle() @@ -507,7 +507,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.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) + a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] const seqs1 = events1.map(e => e.seq) @@ -534,7 +534,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.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } }) + a2.followup([{ 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 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 86981801ff..fc4267cc49 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -56,7 +56,7 @@ 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. `target: 'next-turn'` (default) queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt 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. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `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. Omitting the whole options object selects `{ target: 'next-turn', wakeup: true, source: { kind: 'user' } }`; a supplied `SendOptions` must provide all three fields. `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 and starts a durability flush without opening a turn; `whenIdle()` and disposal await that flush. Injection emits no `agent/inbox/*` event. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e9524484a9..1e2ad1b4a3 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -57,25 +57,29 @@ export type SendTarget = 'next-turn' | 'next-step' * (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and * {@link Agent.inject} (`next-step`/no-wakeup). * - * An omitted source attests direct human input as `{ kind: 'user' }` and may - * authorize policy consumers, so non-human producers must label their content. + * Omitting the whole options object selects the ordinary user-message preset. + * A supplied object is complete so its routing and provenance are explicit. */ export interface SendOptions { - /** Queue the item joins; defaults to `next-turn`. */ - target?: SendTarget + /** Queue the item joins. */ + target: SendTarget /** * Whether this item makes the model run: wake a parked driver (`next-turn`) - * or force a continuation step (`next-step` while running). Defaults to - * `true`. A `false` `next-turn` item queues without waking; a `false` + * or force a continuation step (`next-step` while running). A `false` + * `next-turn` item queues without waking; a `false` * `next-step` item attaches durable context without forcing another step * (the injection preset). */ - wakeup?: boolean - source?: MessageSource + 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 type AliasSendOptions = Omit +export interface AliasSendOptions { + /** Producer provenance; each alias supplies its documented default when omitted. */ + source?: MessageSource +} /** * Opaque id assigned to one accepted {@link Agent.send} message; returned by @@ -225,7 +229,11 @@ export abstract class Agent { * @returns the accepted message's {@link AgentMessageId}. */ followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-turn', wakeup: true }) + return this.send(content, { + target: 'next-turn', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + }) } /** @@ -240,7 +248,11 @@ export abstract class Agent { * @returns the accepted message's {@link AgentMessageId}. */ steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: true }) + return this.send(content, { + target: 'next-step', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + }) } /** @@ -254,7 +266,11 @@ export abstract class Agent { * @returns the accepted message's {@link AgentMessageId}. */ inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { ...options, target: 'next-step', wakeup: false }) + return this.send(content, { + target: 'next-step', + wakeup: false, + source: options?.source ?? { kind: 'plugin', plugin: '' }, + }) } /** diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index aefa739dde..671b5fa559 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -9,7 +9,14 @@ import AgentRegistry, { agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' -import type { AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { + AgentCancelCause, + AgentFactory, + ContinuationStop, + CreateAgentOptions, + ResumeAgentOptions, + SendOptions, +} from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string, overrides: Partial = {}): Agent { const id = SessionId(rawId) @@ -28,6 +35,28 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { }) } +describe('Agent delivery aliases', () => { + it('materializes complete SendOptions for every preset', () => { + const calls: SendOptions[] = [] + const agent = stubAgent('aliases', { + send(_content, options) { + if (options !== undefined) calls.push(options) + return AgentMessageId('stub') + }, + }) + + agent.followup([]) + agent.steer([]) + agent.inject([]) + + expect(calls).toEqual([ + { target: 'next-turn', wakeup: true, source: { kind: 'user' } }, + { target: 'next-step', wakeup: true, source: { kind: 'user' } }, + { target: 'next-step', wakeup: false, source: { kind: 'plugin', plugin: '' } }, + ]) + }) +}) + describe('AgentRegistry', () => { it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => { type TurnStopListener = Events['agent/turn-stop'] diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 35522c9372..ac5cd2ff54 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -219,7 +219,7 @@ export function apply(ctx: Context): void { } state.attempt = reservation try { - agent.send(content, { + agent.followup(content, { source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round }, }) } catch (error: unknown) { diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index d4994466f9..30f6f576d2 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -609,7 +609,7 @@ describe('same-session goal driving', () => { it('blocks forged goal attribution without touching an absent reservation', async () => { const test = await harness([]) - test.agent.send([{ type: 'text', text: 'forged automatic work' }], { + test.agent.followup([{ type: 'text', text: 'forged automatic work' }], { source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 }, }) await test.agent.whenIdle() diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 0858394f5a..52f537849a 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -437,7 +437,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { if (mode === 'steer') agent.steer(content, { source }) - else agent.send(content, { source }) + else agent.followup(content, { source }) } catch (error: unknown) { // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 6a139c3e76..37f9a5dd44 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1092,7 +1092,7 @@ export function apply(ctx: Context, config: AcpConfig): void { if (additionalContext !== undefined) { rec.agent.inject(additionalContext.content, { source: additionalContext.source }) } - rec.agent.send(preparedContent, { source: { kind: 'user' } }) + rec.agent.followup(preparedContent, { source: { kind: 'user' } }) }) return { stopReason } }, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 67f2cd4a29..030ff8bec7 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2507,7 +2507,7 @@ export function createTuiChat( } else if (agent.status === 'running') { agent.steer(content, { source: { kind: 'user' } }) } else { - agent.send(content, { source: { kind: 'user' } }) + agent.followup(content, { source: { kind: 'user' } }) } }