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

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