Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance
# Conflicts: # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # docs/cordis-catalog/services.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/core/session/README.i18n.yaml # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/ui/user-approval/src/index.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
|
||||
README.md: a65c53b3e4edf2031f286d7d172e73357c66ff1e
|
||||
README.zh.md: 05da8a0a0d3ad2ed879b72e20eae1c4efaf711c8
|
||||
README.md: bb48fd8b227484a43af8f9f9e55f8adc8b990ce6
|
||||
README.zh.md: 531db9905b3c091a5c129d31e944e4095e66123b
|
||||
|
||||
@@ -40,7 +40,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
|
||||
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
|
||||
|
||||
### Live events
|
||||
|
||||
@@ -48,22 +48,22 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent injected `user/message` (plugin/goal source); `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
|
||||
`PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative.
|
||||
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
### Agent interface (`types.ts`)
|
||||
|
||||
`Agent` is a structural interface. `followup()`, `queue()`, `steer()`, and `inject()` name common caller intents; `send(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `followup()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content.
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.followup(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message.
|
||||
- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
|
||||
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
|
||||
- `agent.send(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata.
|
||||
- `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
- `agent.send(input, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `input` is the existing `UserMessageData { content, source }`, while `SendOptions` requires only the routing policy `target` and `wakeup`. The agent snapshots and freezes `input` before publication or queueing, so later caller or observer mutation cannot change the accepted message. 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; enqueue also carries the resolved `queued | steering` placement so listeners never reconstruct acceptance-time routing from later state. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
|
||||
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
|
||||
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.
|
||||
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
|
||||
- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement.
|
||||
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
|
||||
|
||||
@@ -81,7 +81,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The four intent helpers and fully resolved `send` path feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
|
||||
`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/step`, and other declared events let plugins block a prompt or add durable request material; this interface contributes no fixed prose itself.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -112,5 +112,5 @@ Prefix-stable while an agent's scoped registrations are unchanged. Setup or relo
|
||||
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
|
||||
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
|
||||
- **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items. There is still no step-only abort that keeps the in-flight turn running ([stop-surface Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
|
||||
- **Each additional `UserMessageData` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
|
||||
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).
|
||||
|
||||
@@ -40,7 +40,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。
|
||||
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖表层属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化静默边界:它停止循环,`await` 循环退出以及每次未完成的空闲注入刷新(而不只是 `disposed` 状态翻转),注销 agent,从存储中移除其会话,最后撤销其作用域世界。该顺序会在分离会话前捕获 agent 启动的每个 `session/flush`,并让作用域监听器存活到这些检查点完成。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖表层属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化静默边界:它停止循环,等待循环退出,注销 agent,从存储中移除其会话,最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。
|
||||
|
||||
### 实时事件
|
||||
|
||||
@@ -48,22 +48,22 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
|
||||
|
||||
大多数拦截点都是返回 seam 专属决策的协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。信号在终止策略执行期间仍是权威来源,并在发布 `turn/end` 前立即退役,因此终止观察方与之后的持久性刷新无法取消已完成的轮次工作。`agent/pre-step` 与 `agent/post-step` 是步骤持久工作前后的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实、不可变的先前重试事实和信号;重试会打开一个新的编号步骤。`agent/turn-stop` 是终止串行 fold:它在普通 continuation 与 steering fold 之后运行;返回的停止会持续到轮次关闭和刷新,因此之后的 steering 不能创建额外步骤或轮次。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
|
||||
`PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源、元数据和放置位置。`SendOptions.contexts` 将同一形状绑定到一条排队消息,并且发生在提示词拦截前:默认允许决策会继续携带它,而被阻止的提示词不记录上下文。缺席或 `separate` 放置会写入独立注入的 `user/message`(plugin/goal 来源);`prompt-prefix` 会把上下文、`## My request:` 分隔符和有效提示词写入一条 `user/message` 或 `steering/message`,其对模型隐藏的 envelope 会保留直接提示词与上下文描述符供人类回放。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。`ContinuationDecision` 原因更窄:它成为不附带上下文元数据的 `steering/message`。
|
||||
`PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源。获准的提示词内容与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。
|
||||
|
||||
轮次和步骤边界以及模型 token 流是持久 `session/event` 事实,而不是镜像的 `agent/*` 通知。消费方从会话 feed 读取 `turn/*`、`step/*` 和 `assistant/chunk`;工具策略与结果观测属于 [`dsh-tools`](../tools/README.md) 记录的完整流水线。
|
||||
|
||||
### Agent 接口(`types.ts`)
|
||||
|
||||
`Agent` 是结构化接口。`followup()`、`queue()`、`steer()` 与 `inject()` 指名常见调用方意图;调用方已经拥有确切路由事实时,`send(ResolvedAgentInput)` 公开同一接受路径([决策](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。每个 `ResolvedAgentInput` 字段均为必填,其可辨识联合会排除附带上下文的非唤醒下一步骤注入。FIFO 接受会返回不透明 `AgentMessageId`,由该条目的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件携带。驱动器会在通知和入队前,把内容、已解析来源、附带上下文与对模型隐藏的元数据快照为一条已分离、深度冻结的无损 JSON 记录;无效数据同步抛出。辅助方法应用默认值:在省略 `options.source` 的 `followup()`、`queue()` 或 `steer()` 调用中,会将直接人类输入声明为 `{ kind: 'user' }`,因此每个非人类生产方都要标记自身内容。
|
||||
每个插件面向的 handle:
|
||||
|
||||
- `agent.followup(content, options?)`:将一条独立 FIFO 消息作为自己的轮次排队,并唤醒驱动器。接纳后,独立上下文成为注入的 `user/message` 事件,而 prompt-prefix 上下文会在同一 `user/message` 中写到有效请求之前;阻止或替换默认附加上下文决策可以丢弃它们。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
|
||||
- `agent.queue(content, options?)`:将相同的普通消息排队,但不唤醒空闲驱动器。单独的排队项会让 `whenIdle()` 保持已解析,并在下一条唤醒消息前一并处理。
|
||||
- `agent.steer(content, options?)`:运行时为下一个检查点排队 steering,且不分发 `agent/prompt-submit`;空闲时创建会唤醒的普通轮次。附带上下文留在同一冻结记录中;独立上下文紧跟 steering 事件追加,prompt-prefix 上下文则写入该事件。二者都能在迟到 steering 转为排队输入时保留,并随消息在取消或终止丢弃时消失。策略仍可以在另一步骤前停止;轮次关闭及其检查点之后,剩余 steering 会成为稍后的排队输入,除非终止轮次策略、取消或释放将其丢弃。
|
||||
- `agent.inject(content, options?)`:接受已分离的会话内上下文而不运行模型;下一次请求会看到其 `user/message`(默认 plugin 来源),其中 `content` 逐字渲染为 user role 消息。`InjectOptions` 有意不提供附带上下文。`options.meta` 持久化不透明 JSON 状态,但不渲染。轮次打开时,注入加入该轮次;当前工具批次执行时会延后 FIFO,如果执行被中断则在轮次关闭前 drain。空闲时,它会被包在一次性 `injection` 轮次和持久性检查点内([轮次包围不变式](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))。注入绕过 FIFO,不发出 `agent/inbox/*` 事件。
|
||||
- `agent.send(input)`:接受完整指定的路由,不应用辅助方法默认值。`next-turn` 指向普通 FIFO;带 wakeup 的 `next-step` 指向 steering,并在空闲时回退为会唤醒的普通轮次;不带 wakeup 的 `next-step` 是注入,且要求 `contexts: []`。调用方没有元数据时也要显式提供 `meta: undefined`。
|
||||
- `agent.cancel(cause?, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作:省略原因表示 `{ kind: 'user' }`;TypeScript 把调用方限制在 `user | parent` 联合中,活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。
|
||||
- `agent.send(input, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`input` 是既有的 `UserMessageData { content, source }`,而 `SendOptions` 只要求路由策略 `target` 与 `wakeup`。agent 会在发布或入队前为 `input` 创建快照并将其冻结,因此调用方或观察方后续的修改无法改变已接受的消息。它返回被接受消息的不透明 `AgentMessageId`,由该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件携带,调用方可据此把排队项与其生命周期关联;入队事件还会携带解析出的 `queued | steering` 路由归类,使监听器无需从后续状态重建接收时的路由。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
|
||||
- `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
|
||||
- `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。
|
||||
- `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。
|
||||
- `agent.acceptsNextStep`:当前发送 `next-step` 时,是否会加入提示词接纳或已打开的轮次。当调用方必须在 steering 与新接纳的提示词之间选择时,应使用这一更窄的路由判定;`status === 'running'` 还涵盖接纳收尾与轮次结算阶段。
|
||||
- `agent.cancel(cause, options?)`:取消活动轮次,并在未设置 `options.keepInbox` 时取消全部待处理工作。调用方必须显式选择 `user | parent` 原因;活动持有者会在中止前把其判别字段复制为已分离、冻结的信号原因。有效调用会在清除排队与 steering 工作前,随原因发出 `agent/cancel-requested`;丢弃项在 `agent/inbox/discard` 上报告,观察方可以同步状态,但不能 veto 取消。`keepInbox: true` 会中止轮次,但保留排队与 steering 项(不丢弃,且不删除尚未开始的工作)。同进程类型化 seam 不会为无类型调用方添加运行时校验或兼容回退。重复取消活动轮次时,首个信号生效;空闲取消是安全空操作,不发通知。ACP 映射到 `user`,进程内父传播映射到 `parent`。原因只存在于运行时;持久 `turn/end` 保持粗粒度的 `aborted`。
|
||||
- `agent.whenIdle()`:agent 从 `running` 结算后达到静默时解析(idle ⇒ 立即;disposed ⇒ 等待循环退出)。这是非拥有者的静默观测钩子:观察工作结算,但不 teardown agent。Teardown 独立存在;生命周期拥有者通过 `AgentHandle.dispose()` 停止并注销,并直接等待循环退出。
|
||||
- `agent.session`、`agent.status`、`agent.options`、`agent.id`
|
||||
|
||||
@@ -81,7 +81,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
#### 模型所见
|
||||
|
||||
四个意图辅助方法与完整解析的 `send` 路径会向所属会话提供输入。`agent/prompt-submit`、`agent/session-prefix` 和其他已声明事件让插件能够阻止提示词或添加请求材料;此接口本身不贡献固定文案。
|
||||
`send`、`steer` 与 `inject` 会向所属会话提供输入。`agent/prompt-submit`、`agent/step` 和其他已声明事件让插件能够阻止提示词或添加持久请求材料;此接口本身不贡献固定文案。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -112,5 +112,5 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
- **委派以外的 agent 间通道**:共享状态、流式子输出和后台/轮询语义仍在当前同步 `ctx.subagents` seam 之外。
|
||||
- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx)` 事务。
|
||||
- **`cancel()` 默认清空 inbox**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项。仍不存在只中止步骤、同时让正在处理的轮次继续运行的操作([停止表层 Agent Note](../../../.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md))。
|
||||
- **`HookContext` 恰好携带一个 `MessageSource`**:多个插件合并到一次工具调用上的贡献会归入一个来源;无法表示混合来源。
|
||||
- **每条附加 `UserMessageData` 恰好携带一个 `MessageSource`**:多个插件合并到一次工具调用上的贡献会归入一个来源;无法表示混合来源。
|
||||
- **`SessionStartSource` 预留 `'clear'`/`'compact'`,但还没有发出方**:在驱动子系统落地前,只会出现 `'startup'`/`'resume'`(`TODO(compaction)`)。
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/** Runtime reason inspection for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */
|
||||
|
||||
import type { AgentInterruptReason } from './types.ts'
|
||||
|
||||
/**
|
||||
* Read a supported agent interruption from an explicitly supplied signal.
|
||||
* Unknown reasons return `undefined`; ambient initiator identity does not grant
|
||||
* cancellation authority.
|
||||
* @param signal - the current turn's explicit control signal.
|
||||
* @returns its canonical reason, or `undefined` while live or unsupported.
|
||||
*/
|
||||
export function agentInterruptReasonOf(signal: AbortSignal): AgentInterruptReason | undefined {
|
||||
if (!signal.aborted) return undefined
|
||||
const reason: unknown = signal.reason
|
||||
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return undefined
|
||||
const prototype = Object.getPrototypeOf(reason) as unknown
|
||||
const keys = Reflect.ownKeys(reason)
|
||||
if ((prototype !== Object.prototype && prototype !== null)
|
||||
|| keys.length !== 1 || keys[0] !== 'kind') return undefined
|
||||
switch ((reason as { readonly kind?: unknown }).kind) {
|
||||
case 'user':
|
||||
return Object.freeze({ kind: 'user' })
|
||||
case 'parent':
|
||||
return Object.freeze({ kind: 'parent' })
|
||||
case 'disposed':
|
||||
return Object.freeze({ kind: 'disposed' })
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,15 @@ export interface AgentEventDispatch {
|
||||
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fused scope carrier for one agent subject.
|
||||
* @param agent - the subject agent and scope key.
|
||||
* @returns the carrier passed as the event dispatcher `this` value.
|
||||
*/
|
||||
export function agentCarrier(agent: Agent): Scoped<Agent> {
|
||||
return scopeTarget(agent, agent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dispatcher that couples the agent subject to its scope carrier.
|
||||
* @param ctx - the context to dispatch through (any context of the app).
|
||||
@@ -73,7 +82,7 @@ export interface AgentEventDispatch {
|
||||
* @returns the fused dispatcher.
|
||||
*/
|
||||
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
|
||||
const carrier = agentCarrier(agent)
|
||||
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
|
||||
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
|
||||
// list for the matching thisArg overload, but TypeScript cannot relate the
|
||||
@@ -111,6 +120,22 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one contained agent notification without allocating a retained dispatcher.
|
||||
* @param ctx - the context to dispatch through.
|
||||
* @param agent - the subject agent and scope key.
|
||||
* @param name - the agent-subject event to emit.
|
||||
* @param rest - the event arguments after the injected agent.
|
||||
*/
|
||||
export function emitAgentEvent<K extends AgentSubjectEvent>(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
name: K,
|
||||
...rest: Tail<K>
|
||||
): void {
|
||||
agentEvents(ctx, agent).emit(name, ...rest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt assembly context with agent and scope set together, so
|
||||
* agent-scoped prompt and tool contributions cannot be silently omitted.
|
||||
|
||||
@@ -15,9 +15,8 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, AgentOptions } from './types.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { agentInterruptReasonOf } from './cancellation.ts'
|
||||
export * from './llm-target.ts'
|
||||
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
||||
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -128,12 +127,8 @@ export interface ResumeAgentOptions {
|
||||
* only the holder can tear this agent down. The registered factory provider is
|
||||
* also a structural owner because the scoped agent depends on that provider's
|
||||
* service surface; provider unload stops and drains every live handle it made.
|
||||
* `dispose()` stops the loop, awaits its exit and every outstanding
|
||||
* idle-injection flush (quiescence — NOT just the `disposed`
|
||||
* status flip), unregisters the agent, removes its session from the store, and
|
||||
* finally unwinds its scoped world. This order captures every agent-started
|
||||
* `session/flush` before the session is detached and keeps scoped listeners
|
||||
* alive through those checkpoints.
|
||||
* `dispose()` stops the loop, awaits its exit, unregisters the agent, removes
|
||||
* its session from the store, and finally unwinds its scoped world.
|
||||
*
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is
|
||||
* exposed only to the consumer owner that created it; the structural provider
|
||||
|
||||
@@ -19,9 +19,6 @@ const install: InvariantInstaller = (ctx, fail) => {
|
||||
if (previous === status) {
|
||||
fail(`agent/status repeated ${status} (no-op transition)`)
|
||||
}
|
||||
if (previous === 'disposed') {
|
||||
fail(`agent/status left terminal state disposed → ${status}`)
|
||||
}
|
||||
lastStatus.set(agent, status)
|
||||
}, { global: true })
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
|
||||
})
|
||||
const disposeRequest = agentCtx.on(
|
||||
'agent/request',
|
||||
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
|
||||
async (_agent, _turn, _step, _signal, next): Promise<LlmCallConfig> => {
|
||||
const resolved = await next()
|
||||
const selected = target.assembled
|
||||
if (selected === undefined) return resolved
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock, LlmCallConfig, LlmFailure, MessageSource, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionId, UserMessageData } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
@@ -27,33 +27,41 @@ export interface AgentOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}.
|
||||
* An omitted source attests direct human input as `{ kind: 'user' }` and may
|
||||
* authorize policy consumers, so non-human producers must label their content.
|
||||
* Which inbox queue a {@link Agent.send} item joins:
|
||||
* - `next-turn` — the item becomes its own turn, claimed at a turn boundary.
|
||||
* - `next-step` — during prompt admission or an open turn, the item stages for
|
||||
* the next safe step boundary; otherwise it is promoted per its `wakeup`
|
||||
* flag.
|
||||
*/
|
||||
export type SendTarget = 'next-turn' | 'next-step'
|
||||
|
||||
/** Resolved inbox placement reported when an accepted message is enqueued. */
|
||||
export type InboxPlacement = 'queued' | 'steering'
|
||||
|
||||
/**
|
||||
* Options for the unified {@link Agent.send} primitive over the
|
||||
* (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup}
|
||||
* (`next-turn`/wakeup), {@link Agent.steer} (`next-step`/wakeup), and
|
||||
* {@link Agent.inject} (`next-step`/no-wakeup).
|
||||
*
|
||||
* The object is complete so routing policy is explicit.
|
||||
*/
|
||||
export interface SendOptions {
|
||||
source?: MessageSource
|
||||
/** Queue the item joins. */
|
||||
target: SendTarget
|
||||
/**
|
||||
* Model-facing contexts captured with this inbox item. A queued prompt exposes
|
||||
* them through the default `agent/prompt-submit` allow decision, while steering
|
||||
* records them directly at its next checkpoint.
|
||||
* Whether this item makes the model run: wake a parked driver (`next-turn`)
|
||||
* 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).
|
||||
*/
|
||||
contexts?: HookContext[]
|
||||
/** Opaque JSON state retained on the durable message but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/** Options specific to durable synthetic context injection. */
|
||||
export interface InjectOptions {
|
||||
/** Defaults to `{ kind: 'plugin', plugin: '' }`; non-human producers should identify themselves. */
|
||||
source?: MessageSource
|
||||
/** Opaque JSON state retained on the durable message but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
wakeup: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque id assigned to one accepted agent input. FIFO inputs carry the same id
|
||||
* on their `agent/inbox/*` events; injection bypasses those events.
|
||||
* Opaque id assigned to one accepted {@link Agent.send} message; returned by
|
||||
* `send` and carried on its `agent/inbox/*` events for correlation.
|
||||
*/
|
||||
export type AgentMessageId = Branded<'AgentMessageId'>
|
||||
|
||||
@@ -67,26 +75,14 @@ export function AgentMessageId(id: string): AgentMessageId {
|
||||
}
|
||||
|
||||
/**
|
||||
* One accepted FIFO message, carried by the `agent/inbox/*` live events. `id`
|
||||
* is the value returned by the accepting helper or {@link Agent.send},
|
||||
* stable across this message's enqueue, dequeue, and discard events. Source
|
||||
* defaults, when applicable, are already applied, so these are the exact values
|
||||
* the item was accepted with.
|
||||
* `steering` is true for an item drained between steps; otherwise it is claimed
|
||||
* at a turn boundary. `SendOptions.meta` is intentionally omitted: it is durable
|
||||
* model-hidden state that lands on the eventual `user/message`/
|
||||
* `steering/message`, not live-event routing data.
|
||||
* One accepted {@link Agent.send} message, carried by the `agent/inbox/*` live
|
||||
* events. `id` is the value `send` returned to the caller, stable across this
|
||||
* message's enqueue, dequeue, and discard events. The agent snapshots and
|
||||
* freezes the accepted content and source before enqueue observers receive it.
|
||||
*/
|
||||
export interface AgentMessage {
|
||||
/** The id returned by the accepting helper or {@link Agent.send}. */
|
||||
export interface AgentMessage extends UserMessageData {
|
||||
/** The id `send` returned for this message. */
|
||||
id: AgentMessageId
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
contexts: HookContext[]
|
||||
/** Whether the item joined the steering FIFO rather than the queued FIFO. */
|
||||
steering: boolean
|
||||
/** Whether the item wakes the driver or requests another step. */
|
||||
wakeup: boolean
|
||||
}
|
||||
|
||||
/** Options for {@link Agent.cancel}. */
|
||||
@@ -102,71 +98,36 @@ export interface CancelOptions {
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (the driver is draining
|
||||
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
|
||||
* transition leaves it, and every delivery method throws).
|
||||
* work and may be closing or checkpointing a turn). Disposal removes the
|
||||
* agent from its registry; it is not a third observable status.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
/** Model-facing context injected by a listener or atomically attached to one inbox message. */
|
||||
export interface HookContext {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
/**
|
||||
* Model placement. Absent or `separate` records an independent injected
|
||||
* `user/message`; `prompt-prefix` prepends this context and a stable
|
||||
* request delimiter to the same user-role message as its attached prompt.
|
||||
*/
|
||||
placement?: 'separate' | 'prompt-prefix'
|
||||
/** Opaque JSON state retained in the session event but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
export type AgentStatus = 'idle' | 'running'
|
||||
|
||||
/**
|
||||
* Fully specified input for {@link Agent.send}. Unlike the intent-named
|
||||
* helpers, this form applies no defaults: callers provide content, source,
|
||||
* contexts, metadata (including explicit `undefined`), target, and wakeup.
|
||||
* The union excludes attached contexts from non-waking next-step injection.
|
||||
*/
|
||||
export type ResolvedAgentInput = {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
meta: JsonValue | undefined
|
||||
} & (
|
||||
| { target: 'next-turn'; wakeup: boolean; contexts: HookContext[] }
|
||||
| { target: 'next-step'; wakeup: true; contexts: HookContext[] }
|
||||
| { target: 'next-step'; wakeup: false; contexts: [] }
|
||||
)
|
||||
|
||||
/**
|
||||
* Prompt interception result. `allow.content` replaces the prompt. Each
|
||||
* `additionalContexts` entry follows its declared placement: separate context
|
||||
* message by default, or a prefix inside the prompt's user-role message.
|
||||
* `block` records a durable `prompt/blocked` and ends the claimed prompt's
|
||||
* zero-step turn as rejected. An `allow` returned by a listener is
|
||||
* authoritative: a listener wrapping `next()` preserves downstream `content`
|
||||
* and `additionalContexts` unless it intentionally replaces them.
|
||||
* Prompt interception result. `allow.content` replaces the prompt, while
|
||||
* `additionalContexts` appends model-facing context before the turn starts.
|
||||
* An `allow` returned by a listener is authoritative: a listener wrapping
|
||||
* `next()` preserves both fields unless it intentionally replaces them.
|
||||
*/
|
||||
export type PromptDecision =
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: UserMessageData[] }
|
||||
| { kind: 'block'; reason: string }
|
||||
|
||||
/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */
|
||||
export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } }
|
||||
|
||||
/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */
|
||||
export type RequestErrorDecision = { action: 'fail' } | { action: 'retry' }
|
||||
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
export type RequestError = Error & { code?: string }
|
||||
|
||||
/** Action returned by a listener that owns model-request recovery. */
|
||||
export type RequestErrorAction = { kind: 'retry' } | undefined
|
||||
|
||||
/**
|
||||
* The terminal subset of {@link ContinuationDecision}. A listener on
|
||||
* `agent/turn-stop` returns this to make the already-composed continuation
|
||||
* outcome terminal; `undefined` abstains.
|
||||
* Why a turn ended, reported live on `agent/settled` right after the turn's
|
||||
* durable `turn/end`. `error` carries the thrown value verbatim for observers;
|
||||
* model-request recovery runs earlier through `agent/request-error`.
|
||||
*/
|
||||
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
export type SettleReason =
|
||||
| { kind: 'completed' }
|
||||
| { kind: 'aborted' }
|
||||
| { kind: 'error'; error: unknown; failure?: LlmFailure }
|
||||
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
@@ -179,7 +140,7 @@ export type AgentCancelCause =
|
||||
/** Runtime reason carried by the signal that controls one live turn. */
|
||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||
|
||||
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
|
||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
@@ -189,84 +150,85 @@ export interface Agent {
|
||||
readonly session: Session
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/**
|
||||
* Whether a `next-step` send currently stages for prompt admission or the
|
||||
* open turn. Unlike {@link status}, this excludes admission exit and turn
|
||||
* settlement, when a waking `next-step` send becomes a queued follow-up.
|
||||
*/
|
||||
readonly acceptsNextStep: boolean
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
/**
|
||||
* Queue an ordinary message as its own FIFO-ordered turn and wake the driver.
|
||||
* Content, resolved source, and attached contexts are detached, validated,
|
||||
* and frozen together; invalid input throws synchronously before notification
|
||||
* or enqueue.
|
||||
* @param content - the prompt content blocks.
|
||||
* @param options - source, attached contexts, and durable model-hidden meta.
|
||||
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
|
||||
* It routes the caller's typed content and source as follows:
|
||||
*
|
||||
* - `next-turn` queues an item that becomes the sole ordinary message of its
|
||||
* own FIFO-ordered turn; `wakeup:true` wakes a
|
||||
* parked driver, while `wakeup:false` queues without waking.
|
||||
* - `next-step` with `wakeup:true` stages steering during prompt admission
|
||||
* or an open turn; outside that window it falls back to a woken
|
||||
* `next-turn`.
|
||||
* - `next-step` with `wakeup:false` injects durable model-facing context
|
||||
* without running the model: admission or an open turn stages it for the
|
||||
* next safe log position, while an injection outside that window appends
|
||||
* immediately without opening a turn. If admission closes without a turn,
|
||||
* a context-only boundary appends immediately; context staged beside
|
||||
* steering remains pending with it.
|
||||
* The agent snapshots and freezes `input` before publishing or queueing it.
|
||||
* @param input - model-facing content and its producer provenance.
|
||||
* @param options - target queue and wakeup decision.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
*/
|
||||
followup(content: ContentBlock[], options?: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Queue an ordinary message without waking an idle driver. The item retains
|
||||
* FIFO order and is claimed only after another input wakes the driver. A lone
|
||||
* queued item leaves `whenIdle()` resolved.
|
||||
* @param content - the prompt content blocks.
|
||||
* @param options - source, attached contexts, and durable model-hidden meta.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
*/
|
||||
queue(content: ContentBlock[], options?: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Submit steering into the running turn and request another step. An open turn
|
||||
* records it at the next steering checkpoint before a request or continuation
|
||||
* decision; policy may stop before another step. After turn close and its
|
||||
* checkpoint, any remainder is queued for a later turn; terminal
|
||||
* `agent/turn-stop`, cancellation, or disposal may discard it. Idle steering
|
||||
* becomes a waking ordinary turn.
|
||||
* @param content - the steering content blocks.
|
||||
* @param options - source, attached contexts, and durable model-hidden meta.
|
||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Append detached model-facing context without running the model. An open-turn
|
||||
* injection joins at the current log position unless the current tool batch is
|
||||
* executing; then it waits FIFO until that batch settles and drains before
|
||||
* turn close even when interrupted. Idle injection uses a one-shot turn and
|
||||
* durability checkpoint. Disposal awaits idle checkpoints; flush failures
|
||||
* report through `agent/error`. An omitted source defaults to
|
||||
* `{ kind: 'plugin', plugin: '' }`.
|
||||
* @param content - the injected context content blocks.
|
||||
* @param options - source and durable model-hidden meta.
|
||||
* @returns the accepted injection's {@link AgentMessageId}; injection emits no `agent/inbox/*` events.
|
||||
*/
|
||||
inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Accept one fully specified input through the same snapshot and routing path
|
||||
* as the four intent-named helpers. `next-turn` targets the ordinary FIFO;
|
||||
* `next-step`/wakeup targets steering (falling back to an ordinary waking turn
|
||||
* while idle); and `next-step` without wakeup injects durable context without
|
||||
* running the model. Every field is mandatory and no source or routing default
|
||||
* is applied. Invalid input throws synchronously before notification, enqueue,
|
||||
* or append.
|
||||
* @param input - the resolved content, attribution, context, metadata, and routing facts.
|
||||
* @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable.
|
||||
*/
|
||||
send(input: ResolvedAgentInput): AgentMessageId
|
||||
send(input: UserMessageData, options: SendOptions): AgentMessageId
|
||||
|
||||
/**
|
||||
* Clear queued and steering work — unless `keepInbox` — and abort the active
|
||||
* turn. An effective call first emits `agent/cancel-requested` with the
|
||||
* resolved typed cause. The first cause wins for the active turn, and
|
||||
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
|
||||
* means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
|
||||
* later work. The active turn snapshots and freezes the cause.
|
||||
* `whenIdle()` resolves after cancellation reaches quiescence. Idle
|
||||
* cancellation is a no-op and does not arm later work.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
||||
*/
|
||||
cancel(cause?: AgentCancelCause, options?: CancelOptions): void
|
||||
cancel(cause: AgentCancelCause, options?: CancelOptions): void
|
||||
|
||||
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||
whenIdle(): Promise<void>
|
||||
|
||||
/**
|
||||
* Queue an ordinary follow-up turn and wake the driver — the
|
||||
* `next-turn`/wakeup preset of {@link send}. The item becomes the sole
|
||||
* ordinary message of its own turn.
|
||||
* @param input - prompt content and its producer provenance.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
*/
|
||||
followup(input: UserMessageData): AgentMessageId
|
||||
|
||||
/**
|
||||
* Submit steering during prompt admission or an open turn — the
|
||||
* `next-step`/wakeup preset of {@link send}. It stages for the next steering
|
||||
* checkpoint before a request or stop decision. If the activity fails before
|
||||
* that boundary, the remainder stays staged without waking the agent; retry
|
||||
* or a later prompt takes it. Outside that window steering falls back to a
|
||||
* woken follow-up turn, while cancellation or disposal may discard pending
|
||||
* steering.
|
||||
* @param input - steering content and its producer provenance.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
*/
|
||||
steer(input: UserMessageData): AgentMessageId
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model — the
|
||||
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
|
||||
* stages it at the next safe log position; outside that window it appends
|
||||
* immediately without opening a turn. If admission closes without a turn,
|
||||
* a context-only boundary appends immediately; context staged beside
|
||||
* steering remains pending with it.
|
||||
* @param input - injected context and its producer provenance.
|
||||
* @returns the accepted message's {@link AgentMessageId}.
|
||||
*/
|
||||
inject(input: UserMessageData): AgentMessageId
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -285,7 +247,7 @@ declare module 'cordis' {
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent left the registry; AgentLoop emits this after driver quiescence
|
||||
* but before session detachment and scoped-registration unwind. Custom
|
||||
* and scoped-registration unwind, but before session detachment. Custom
|
||||
* registry users own their driver-ordering contract.
|
||||
* @param agent - the exact agent removed from the registry.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -293,8 +255,8 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). A waking
|
||||
* delivery does not enter `running` synchronously; drive lifecycle from this event.
|
||||
* Agent status changed (`idle` ⇄ `running`). `send()` does not enter
|
||||
* `running` synchronously; drive lifecycle from this event.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -302,18 +264,16 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* A detached, frozen item entered the agent's inbox (queued or steering
|
||||
* FIFO). Source defaults are already applied, so `message` holds the exact
|
||||
* accepted values. This is the enqueue-time live signal; the durable record
|
||||
* is the eventual `user/message`/`steering/message`. Injection through
|
||||
* `agent.inject()` or equivalent `send()` routing bypasses the FIFOs
|
||||
* and does not emit this.
|
||||
* @param agent - the agent whose inbox received the item.
|
||||
* @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).
|
||||
* An item entered the queued or steering inbox. `placement` is the
|
||||
* acceptance-time routing result; listeners must not reconstruct it from
|
||||
* later agent or session state.
|
||||
* @param agent - the owning agent.
|
||||
* @param message - accepted content, source, and correlation identity.
|
||||
* @param placement - resolved queued or steering placement.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
|
||||
'agent/inbox/enqueue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage, placement: InboxPlacement): void
|
||||
/**
|
||||
* The driver claimed one item out of the inbox: a queued item at a turn
|
||||
* boundary, or steering drained between steps. Fires after the item leaves
|
||||
@@ -327,11 +287,9 @@ declare module 'cordis' {
|
||||
/**
|
||||
* Pending inbox items were dropped without delivering them, so every
|
||||
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
|
||||
* `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after
|
||||
* `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`
|
||||
* dropping pending steering (in-turn and on the post-turn late-steering
|
||||
* drain); and disposal of any still-pending items (before
|
||||
* `agent/status('disposed')`). Fires once per drop with every dropped item.
|
||||
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
|
||||
* emits this after `agent/cancel-requested` when applicable and before
|
||||
* aborting the active work. Fires once per drop with every dropped item.
|
||||
* @param agent - the agent whose inbox items were dropped.
|
||||
* @param messages - the discarded messages in FIFO order (queued then steering); never empty.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
@@ -339,11 +297,11 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/inbox/discard'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void
|
||||
/**
|
||||
* Effective broad cancellation was requested, before queued/steering work
|
||||
* Effective broad cancellation was requested, before queued/outbox work
|
||||
* is cleared or the active turn is aborted. This observe-only notification
|
||||
* cannot veto cancellation; listener failures are contained.
|
||||
* @param agent - the agent whose current work is being cancelled.
|
||||
* @param cause - resolved typed cancellation cause, including the default.
|
||||
* @param cause - the explicit typed cancellation cause.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -362,29 +320,12 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
|
||||
// Turn and step boundaries are durable session events, not agent events.
|
||||
|
||||
// ---- step/request extension seams (serial + waterfall) ----
|
||||
/**
|
||||
* Awaited serial checkpoint before `step/start`; appends land outside the
|
||||
* pending step and are included when the loop derives request history.
|
||||
* `signal` cancels listener work.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent opening the step.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the pending step number.
|
||||
* @param signal - the turn abort signal.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
// ---- the machine's extension seams ----
|
||||
/**
|
||||
* Allow, rewrite, or block one claimed prompt before it becomes a user
|
||||
* message. Call `next()` for the unchanged default. A listener wrapping a
|
||||
* downstream `allow` must preserve its `content` and `additionalContexts`
|
||||
* unless it intentionally replaces them. The signal controls only this turn;
|
||||
* listeners may cooperate with it but must not retain it to control another
|
||||
* turn. Steering messages do not dispatch this event; they join an open turn
|
||||
* at a steering checkpoint.
|
||||
* message or opens a turn. Call `next()` for the unchanged default. The
|
||||
* signal controls only this admission attempt; listeners may cooperate with
|
||||
* it but must not retain it for a later attempt or turn.
|
||||
* @param agent - the agent whose turn claimed the message.
|
||||
* @param content - the claimed message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
@@ -394,100 +335,84 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Replace the frozen call configuration. Model-visible content must use
|
||||
* logged channels; this seam cannot mutate messages. Injection here joins
|
||||
* the next request because the current step boundary is already fixed.
|
||||
* @param agent - the agent making the model call.
|
||||
* Awaited serial checkpoint before EVERY request of a turn is built (the
|
||||
* first as well as each post-tools continuation). The single "between
|
||||
* steps" extension point: inject context, steer, or edit the session log
|
||||
* here — the request's history derives from the log right after this settles.
|
||||
* @param agent - the agent about to send a request.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param config - the config the loop would use (frozen); return a replacement to switch.
|
||||
* @param signal - the current turn's explicit abort signal; ambient
|
||||
* initiator identity does not imply liveness or cancellation authority.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Compose request-only messages placed before derived history. The frozen
|
||||
* result is computed once per loop instance, logged on its anchoring request
|
||||
* header, and reused so the provider prefix remains stable. Interrupted
|
||||
* composition is discarded. Composition precedes the first `agent/pre-step`
|
||||
* and request boundary, so listener appends join the current request.
|
||||
* Changing context belongs in history; contributors should prepend to
|
||||
* `await next()` to preserve registration order.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen seed; return an extended replacement.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
* @param agent - the agent that received the step's response.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step that produced the message.
|
||||
* @param message - the assistant message as assembled from the stream.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>
|
||||
/**
|
||||
* Awaited serial checkpoint after the response, real or synthetic tool
|
||||
* results, injected context, and steering are durable but before `step/end`.
|
||||
* A cancelled tool batch reaches this checkpoint with an aborted signal.
|
||||
* @param agent - the agent whose step is settling.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the open step number.
|
||||
* @param step - the step number about to open.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/post-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
'agent/step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Recover a model-request failure after its failed step has closed. `retry`
|
||||
* opens a new numbered step; `fail` preserves the original request error.
|
||||
* Call `next()` to delegate to the next recovery listener or the default.
|
||||
* Replace the frozen call configuration. `await next()` yields the config
|
||||
* the machine would use (agent options on the first request, the logged
|
||||
* header afterwards); return a replacement to switch. Model-visible
|
||||
* content must use logged channels; this seam cannot mutate messages.
|
||||
* @param agent - the agent making the model call.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Handle a model-request failure after its failed step has closed but
|
||||
* before the failed turn closes. A listener returns `{ kind: 'retry' }`
|
||||
* without calling `next()` when it owns the error, or calls `next()` to
|
||||
* delegate. The default `undefined` leaves the failure terminal.
|
||||
* @param agent - the agent whose request failed.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the failed step number.
|
||||
* @param error - the original model-request failure.
|
||||
* @param failure - serializable facts normalized at the final adapter boundary.
|
||||
* @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.
|
||||
* @param priorFailures - immutable failures that already authorized another
|
||||
* retry turn in this consecutive sequence.
|
||||
* @param retryPolicy - immutable policy of the adapter registration that served
|
||||
* the failed request, or `undefined` if no final adapter served it.
|
||||
* @param signal - the turn abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
|
||||
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], retryPolicy: ResolvedRetryPolicy | undefined, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
|
||||
/**
|
||||
* Override whether the turn continues. The default continues after tool
|
||||
* calls or steering and stops otherwise; a continue reason becomes steering.
|
||||
* @param agent - the agent deciding whether to run another step.
|
||||
* @param turn - the turn being continued or stopped.
|
||||
* @param defaultDecision - what the loop would do absent an override.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
/**
|
||||
* Monotonic terminal-stop checkpoint after continuation and steering are
|
||||
* folded; a stop remains authoritative through turn close and flush:
|
||||
* steering queued in that window is discarded, while ordinary sends survive.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* The turn is about to close: the model owes no response (no live tool
|
||||
* calls, no fresh steering). Awaited before the boundary commits — a
|
||||
* listener that objects steers (`agent.steer(...)`) and the machine
|
||||
* re-reads its inbox: fresh steering runs another step, none closes the
|
||||
* turn. Data decides, so listener order cannot change the outcome. The
|
||||
* inverse control (stop a tool loop early) is data too: a tool result
|
||||
* carrying `concludesTurn` ends the turn at its step.
|
||||
* @param agent - the agent whose turn is at its stop boundary.
|
||||
* @param turn - the turn about to close.
|
||||
* @param signal - the current turn's explicit abort signal.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* One drain chain reached its terminal turn: that turn's `turn/end` is
|
||||
* already committed. Automatically recovered failed turns do not emit this
|
||||
* notification, and neither does a run that aborts or fails before its
|
||||
* `turn/start` commits — there is no durable turn to settle against.
|
||||
* `reason` says why; model-request recovery is exhausted when an error
|
||||
* reaches it.
|
||||
* @param agent - the agent whose turn closed.
|
||||
* @param turn - the terminal turn number.
|
||||
* @param reason - why the terminal turn ended, with live error facts when it failed.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/settled'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
* A step or turn errored. The loop reports a failure here (plus the logger)
|
||||
* even when the error has no in-turn position for a session `error` event.
|
||||
* A step or turn errored. The machine reports a failure here (plus the
|
||||
* logger) even when the error has no in-turn position for a durable record.
|
||||
* @param agent - the agent whose turn errored.
|
||||
* @param turn - the turn in which the failure surfaced.
|
||||
* @param step - the step at which the failure surfaced.
|
||||
@@ -495,6 +420,6 @@ declare module 'cordis' {
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,65 +5,36 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, {
|
||||
AgentMessageId,
|
||||
agentEvents,
|
||||
agentInterruptReasonOf,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentCancelCause,
|
||||
AgentFactory,
|
||||
ContinuationStop,
|
||||
CreateAgentOptions,
|
||||
InjectOptions,
|
||||
ResolvedAgentInput,
|
||||
ResumeAgentOptions,
|
||||
SendOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
const id = SessionId(rawId)
|
||||
return {
|
||||
const agent: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
session: new Session(id),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send: () => AgentMessageId('stub'),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
queue: () => AgentMessageId('stub'),
|
||||
steer: () => AgentMessageId('stub'),
|
||||
inject: () => AgentMessageId('stub'),
|
||||
send: () => AgentMessageId('stub'),
|
||||
cancel() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
...overrides,
|
||||
}
|
||||
return Object.assign(agent, overrides)
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
it('keeps helper options semantic and makes advanced input fully specified', () => {
|
||||
type OptionalInputKey = {
|
||||
[Key in keyof ResolvedAgentInput]-?: Record<never, never> extends Pick<ResolvedAgentInput, Key>
|
||||
? Key
|
||||
: never
|
||||
}[keyof ResolvedAgentInput]
|
||||
|
||||
expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
|
||||
expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
|
||||
expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf<false>()
|
||||
expectTypeOf<Parameters<Agent['send']>[0]>().toEqualTypeOf<ResolvedAgentInput>()
|
||||
expectTypeOf<OptionalInputKey>().toEqualTypeOf<never>()
|
||||
expectTypeOf<Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>['contexts']>()
|
||||
.toEqualTypeOf<[]>()
|
||||
})
|
||||
|
||||
it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => {
|
||||
type TurnStopListener = Events['agent/turn-stop']
|
||||
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
|
||||
|
||||
expectTypeOf<AsyncTurnStopListener>().toExtend<TurnStopListener>()
|
||||
expectTypeOf<Awaited<ReturnType<TurnStopListener>>>().toEqualTypeOf<ContinuationStop | undefined>()
|
||||
})
|
||||
|
||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -215,38 +186,11 @@ describe('agentEvents()', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('explicit cancellation helpers', () => {
|
||||
describe('explicit cancellation contract', () => {
|
||||
it('exposes the closed typed cancellation cause at the Agent seam', () => {
|
||||
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause | undefined>()
|
||||
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
|
||||
expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
|
||||
})
|
||||
|
||||
it('reads only supported reasons from an explicit signal', () => {
|
||||
const read = (reason: unknown) => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(reason)
|
||||
return agentInterruptReasonOf(controller.signal)
|
||||
}
|
||||
const live = new AbortController()
|
||||
expect(agentInterruptReasonOf(live.signal)).toBeUndefined()
|
||||
|
||||
expect(read({ kind: 'user' })).toEqual({ kind: 'user' })
|
||||
expect(read({ kind: 'parent' })).toEqual({ kind: 'parent' })
|
||||
|
||||
const disposed = new AbortController()
|
||||
disposed.abort(Object.assign(Object.create(null) as object, { kind: 'disposed' }))
|
||||
const disposedReason = agentInterruptReasonOf(disposed.signal)
|
||||
expect(disposedReason).toEqual({ kind: 'disposed' })
|
||||
expect(Object.isFrozen(disposedReason)).toBe(true)
|
||||
|
||||
expect(read(null)).toBeUndefined()
|
||||
expect(read([])).toBeUndefined()
|
||||
expect(read('private runtime reason')).toBeUndefined()
|
||||
expect(read(new Error('private runtime reason'))).toBeUndefined()
|
||||
expect(read({ kind: 'user', detail: true })).toBeUndefined()
|
||||
expect(read({ other: 'user' })).toBeUndefined()
|
||||
expect(read({ kind: 'timeout' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
|
||||
@@ -17,19 +17,14 @@ function mockAgent(id: string): Agent {
|
||||
}
|
||||
|
||||
describe('agent status invariants', () => {
|
||||
it('accepts lifecycle transitions through idle, running, and disposed', async () => {
|
||||
it('accepts lifecycle transitions between idle and running', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('a1')
|
||||
expect(() => {
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
}).not.toThrow()
|
||||
|
||||
const running = mockAgent('a2')
|
||||
ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running')
|
||||
expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a no-op transition', async () => {
|
||||
@@ -40,14 +35,6 @@ describe('agent status invariants', () => {
|
||||
.toThrow(/no-op transition/)
|
||||
})
|
||||
|
||||
it('rejects leaving the terminal disposed state', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('a4')
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') })
|
||||
.toThrow(/left terminal state disposed/)
|
||||
})
|
||||
|
||||
it('tracks agents independently', async () => {
|
||||
const ctx = await setup()
|
||||
const a = mockAgent('a5')
|
||||
@@ -58,24 +45,24 @@ describe('agent status invariants', () => {
|
||||
})
|
||||
|
||||
describe('agent inbox invariants', () => {
|
||||
const info = (steering: boolean) => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const }, contexts: [], steering, wakeup: true })
|
||||
const info = () => ({ id: AgentMessageId('m'), content: [], source: { kind: 'user' as const } })
|
||||
|
||||
it('accepts a dequeue and a discard covered by prior enqueues', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('i1')
|
||||
const at = scopeTarget(agent, agent)
|
||||
expect(() => {
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(true))
|
||||
ctx.emit(at, 'agent/inbox/dequeue', agent, info(false))
|
||||
ctx.emit(at, 'agent/inbox/discard', agent, [info(true)])
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued')
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'steering')
|
||||
ctx.emit(at, 'agent/inbox/dequeue', agent, info())
|
||||
ctx.emit(at, 'agent/inbox/discard', agent, [info()])
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a dequeue with no outstanding item', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('i2')
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(false)) })
|
||||
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) })
|
||||
.toThrow(/without a matching prior enqueue/)
|
||||
})
|
||||
|
||||
@@ -83,8 +70,8 @@ describe('agent inbox invariants', () => {
|
||||
const ctx = await setup()
|
||||
const agent = mockAgent('i3')
|
||||
const at = scopeTarget(agent, agent)
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(false))
|
||||
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(false), info(true)]) })
|
||||
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued')
|
||||
expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) })
|
||||
.toThrow(/dropped 2 items but only 1 were outstanding/)
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => {
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
|
||||
target.current = {
|
||||
@@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
|
||||
target.current = { provider: 'beta', model: 'b1' }
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toEqual({
|
||||
provider: 'alpha',
|
||||
model: 'a1',
|
||||
@@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => {
|
||||
temperature: 0.2,
|
||||
}
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, inherited, signal, () => Promise.resolve(inherited),
|
||||
'agent/request', 1, 1, signal, () => Promise.resolve(inherited),
|
||||
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
|
||||
|
||||
dispose()
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 2, 0, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 2, 0, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user