Jelajahi Sumber

refactor(agent): require complete send options

_Kerman 1 bulan lalu
induk
melakukan
e23960b8ad

+ 14 - 22
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<PreparedReferencedMessage>',
-        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: 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: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>',
-    jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default, 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<void>;\n    followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n    steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n    inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n    abstract retry(): void;\n}',
@@ -1181,7 +1185,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'AliasSendOptions',
-    declaration: 'export type AliasSendOptions = Omit<SendOptions, \'target\' | \'wakeup\'>;',
+    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',

+ 6 - 5
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) {

+ 15 - 3
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).

+ 5 - 5
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()
 

+ 1 - 1
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)

+ 5 - 1
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)

+ 6 - 6
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

+ 1 - 1
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.

+ 28 - 12
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<SendOptions, 'target' | 'wakeup'>
+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: '' },
+    })
   }
 
   /**

+ 30 - 1
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> = {}): Agent {
   const id = SessionId(rawId)
@@ -28,6 +35,28 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): 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']

+ 1 - 1
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) {

+ 1 - 1
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()

+ 1 - 1
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) } })

+ 1 - 1
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 }
       },

+ 1 - 1
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' } })
     }
   }