refactor(agent): require complete send options

This commit is contained in:
_Kerman
2026-07-24 16:23:20 +08:00
parent 45fc7fda3d
commit e23960b8ad
15 changed files with 116 additions and 62 deletions

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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).

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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: '' },
})
}
/**

View File

@@ -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']

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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) } })

View File

@@ -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 }
},

View File

@@ -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' } })
}
}