Merge pull request #6738 from deepseek-harness/revert-6418-worktree/session-agent-controller-refactor-ff892d

Revert "feat(agent): admit Web prompts through the agent controller"
This commit is contained in:
Turtle authored and GitHub committed 2026-10-20 19:28:42 +08:00
commit 358ce64945
65 files changed
+111 -2172

No files matched your search

@@ -1,19 +0,0 @@
# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller:
en: 9105f8148e107f43
zh: a160e87bb0605da4
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem:
en: 5071232d9743096c
zh: b553a626538b3466
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
en: 1b61d351c4d2bdf1
zh: d6f8180b30d97b34
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
en: acc465cfe02b624c
zh: db1d06574f647130
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences:
en: 9d8486586d044709
zh: 24c7249421fd2541
@@ -1,51 +0,0 @@
# Agent Note: Delivering messages from outside an Agent through the Agent controller
Status: implemented
English | [中文](2026-10-17-agent-controller-message-delivery.zh.md)
## Problem
Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client.
## Decision
The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged.
| Part | Contract |
|---|---|
| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox |
| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake |
| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails |
| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it |
| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled |
| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox |
Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message.
`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice.
The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check.
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile.
## Alternatives considered
- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract.
- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it.
- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts.
- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing.
- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it.
- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`.
The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it.
## Consequences
- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed.
- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft.
- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced.
- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt.
- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point.
@@ -1,51 +0,0 @@
# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息
Status: implemented
[English](2026-10-17-agent-controller-message-delivery.md) | 中文
## 问题
插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。
## 决策
core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。
| 部分 | 约定 |
|---|---|
| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id |
| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 |
| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 |
| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 |
| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 |
| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 |
只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。
`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。
Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。
## 备选方案
- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。
- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。
- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。
- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。
- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。
- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。
PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。
## 影响
- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。
- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。
- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。
- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。
- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。
-3
View File
@@ -40,8 +40,6 @@ flowchart LR
cfg --> plugin_dsh_base_user_questions
plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
cfg --> plugin_dsh_base_agent
plugin_dsh_base_agent_controller["agent-controller<br/>@deepseek-ai/dsh-agent-controller"]
cfg --> plugin_dsh_base_agent_controller
plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek<br/>@deepseek-ai/dsh-plugin-package-inventory-deepseek"]
cfg --> plugin_dsh_base_plugin_package_inventory_deepseek
plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
@@ -220,7 +218,6 @@ flowchart LR
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` |
| `user-questions` | `@deepseek-ai/dsh-user-questions` |
| `agent` | `@deepseek-ai/dsh-agent` |
| `agent-controller` | `@deepseek-ai/dsh-agent-controller` |
| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` |
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
| `jobs` | `@deepseek-ai/dsh-jobs-local` |
+4 -4
View File
@@ -18,11 +18,11 @@
en: b9eeb18645121a8b
zh: 70d87a12c43e042a
/deepseek-harness-architecture/core-packages:
en: f4dde8809a713bad
zh: ccbee814b4e8b4b7
en: 20ef882e44b06a10
zh: c10c3934ed5570e9
/deepseek-harness-architecture/events:
en: c00467ff3a210ef5
zh: 7619d7cca723fa6b
en: e89802f066e4955d
zh: ea2fb4bef94e086d
/deepseek-harness-architecture/turn-flow:
en: e6402b28c0ed15fa
zh: 6c07be9047b42056
-2
View File
@@ -61,7 +61,6 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes
| [`core/tools`](subsystems/tools.md) | The scoped tool registry and guarded execution pipeline | `ctx.tools` |
| [`core/agent`](subsystems/core.md) | The `Agent` interface, live registry, and `agent/*` events | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.md) | The default driver implementing that interface | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.md#messages-from-outside-an-agent) | Admission and delivery of outside messages | `ctx.agentController` |
| [`core/scope`](subsystems/scope.md) | The per-agent scoped-registration primitive | library, no key |
| [`llm/llm`](subsystems/llm-streaming.md) | Message and stream vocabulary plus the adapter seam | `ctx.llm` |
@@ -71,7 +70,6 @@ Events are the extension points, and picking the right domain is the first decis
- **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload.
- **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
- **Delivery admission** (`agent-controller/admission`) holds or refuses outside input before it enters the inbox.
- **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering.
-2
View File
@@ -61,7 +61,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
| [`core/tools`](subsystems/tools.zh.md) | 作用域化的工具注册表和带把关的执行流水线 | `ctx.tools` |
| [`core/agent`](subsystems/core.zh.md) | `Agent` 接口、活跃 agent 注册表和 `agent/*` 事件 | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.zh.md) | 实现该接口的默认驱动器 | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.zh.md#messages-from-outside-an-agent) | 外部消息的准入与投递 | `ctx.agentController` |
| [`core/scope`](subsystems/scope.zh.md) | 按 agent 划分作用域的注册原语 | 库,无 ctx 键 |
| [`llm/llm`](subsystems/llm-streaming.zh.md) | 消息与流式词汇表,以及适配器 seam | `ctx.llm` |
@@ -73,7 +72,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
- **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。
- **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
- **投递准入**(`agent-controller/admission`)在外部输入进入收件箱之前暂留或拒绝它。
- **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 3e87315977a89b5a
zh: 626a75f3bebaa39f
en: 753b3da3abc6aeea
zh: aab02ec16c3f744b
-5
View File
@@ -191,8 +191,6 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -300,7 +298,6 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -459,7 +456,6 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -690,7 +686,6 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone's behalf, one at a time per Agent. |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. |
-5
View File
@@ -193,8 +193,6 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -302,7 +300,6 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -461,7 +458,6 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -692,7 +688,6 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),提供缓存投影视图,并加速 prepared Session 的投影恢复。 |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | 准备发送方代表他人送来的消息,经 agent-controller/admission 阶段接纳后放入收件箱,每个 Agent 逐条处理。 |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 |
+1 -2
View File
@@ -225,7 +225,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4698,7 +4698,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
+1 -2
View File
@@ -227,7 +227,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4700,7 +4700,6 @@ export interface Config {
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
+5 -6
View File
@@ -8,7 +8,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -23,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+5 -6
View File
@@ -10,7 +10,6 @@
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -25,11 +24,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+1 -7
View File
@@ -41,7 +41,6 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -641,9 +640,6 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1275,7 +1271,6 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1656,7 +1651,6 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1775,7 +1769,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+1 -7
View File
@@ -43,7 +43,6 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -643,9 +642,6 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1277,7 +1273,6 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1658,7 +1653,6 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1777,7 +1771,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+4 -4
View File
@@ -1347,8 +1347,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: 324d7ebffee10aad
zh: 31f7b8a18571665a
en: 41c03b03d1bd5a44
zh: 1b0e430af6c140b3
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1833,8 +1833,8 @@
en: 809a23c25ded8497
zh: e9cd23e281f8d31f
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: db30e659a1cc4c06
zh: 8585ea4c8178ab37
en: 53b7abac679ee252
zh: 63eb6a015986ef6d
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
+2 -2
View File
@@ -5583,7 +5583,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
Sources: [`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
来源:[`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -23574,7 +23574,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:412",
"packages/api/session-controller/src/types.ts:406",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:21",
@@ -24096,7 +24096,7 @@
"digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def",
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:421"
"packages/api/session-controller/src/types.ts:415"
]
},
{
+3 -6
View File
@@ -6,8 +6,8 @@
en: f6ae07d8902e8edf
zh: c92fef531c6da331
/core/the-spine-package-by-package:
en: 60926dc99f41024d
zh: f40370b034f91463
en: c90cb23528ddb6c8
zh: 62724b7255b586b6
/core/creation-and-ownership:
en: ab09a38b4e1f0883
zh: 81e2d14c4036ab6c
@@ -16,10 +16,7 @@
zh: 25bc9cb60ee1520a
/core/initiating-agent:
en: 6c8c47ac78de2dde
zh: acde99c8808ea891
/core/messages-from-outside-an-agent:
en: cf087fd9ff5edb87
zh: c3bb56b17cd76c52
zh: c9d527a25a85d1d9
/core/interception-decisions:
en: 6fccceb0a5f1cad9
zh: f43c30c2386d8cc8
+1 -125
View File
@@ -15,10 +15,9 @@ A turn flows through the six packages in one loop: the driver in [`agent-loop`](
| `tools/` | The scoped tool registry and guarded execution pipeline (`ctx.tools`) | [tools.md](tools.md) |
| `agent/` | The `Agent` interface, live registry, initiator scope, and `agent/*` event vocabulary (`ctx.agents`) | this page |
| `agent-loop/` | The concrete driver implementing the public `Agent` contract (`ctx.agentLoop`) | this page |
| `agent-controller/` | Delivery of messages from outside an Agent through the `agent-controller/admission` stage (`ctx.agentController`) | this page |
| `scope/` | The scoped-registration primitive the registries and loop build per-agent scoping on | [scope.md](scope.md) |
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. `agent-controller` sits in front of the loop: a sender that brings a message on someone's behalf delivers it through `ctx.agentController` instead of appending to the inbox itself. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
## Creation and ownership
@@ -306,65 +305,6 @@ The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, ch
The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules.
## Messages from outside an Agent
A sender that brings a message on someone's behalf, such as the Web Session Remote, calls `ctx.agentController.deliver` from [`agent-controller`](../../packages/core/agent-controller/README.md) instead of `followup()` or `steer()`. The controller prepares the content in the Agent's delivery order, asks the `agent-controller/admission` waterfall for a decision, and appends an admitted message; messages the Agent's own machinery produces enter without the stage. The [delivery decision](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md) explains why the stage lives outside `agent`.
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
A listener admits by returning the decision of `next()`, holds the message by awaiting before `next()`, and refuses it by returning a `reject` decision:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
## Interception decisions
Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result.
@@ -497,33 +437,6 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1186,43 +1099,6 @@ Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+1 -127
View File
@@ -15,10 +15,9 @@
| `tools/` | 带作用域的工具注册表与受保护的执行流水线(`ctx.tools`) | [tools.md](tools.zh.md) |
| `agent/` | `Agent` 接口、实时注册表、发起者作用域与 `agent/*` 事件词汇(`ctx.agents`) | 本页 |
| `agent-loop/` | 实现公开 `Agent` 约定的具体 driver(`ctx.agentLoop`) | 本页 |
| `agent-controller/` | 外部消息经 `agent-controller/admission` 阶段投递进 Agent(`ctx.agentController`) | 本页 |
| `scope/` | 注册表与循环用于构建按 agent 作用域的注册原语 | [scope.md](scope.zh.md) |
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。`agent-controller` 位于循环之前:代表他人送来消息的发送方经 `ctx.agentController` 投递,而不是自己写入收件箱。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
<a id="creation-and-ownership"></a>
@@ -312,67 +311,6 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有
`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md)定义其生命周期和作用域规则。
<a id="messages-from-outside-an-agent"></a>
## 来自 Agent 外部的消息
代表他人送来消息的发送方(例如 Web Session Remote)调用 [`agent-controller`](../../packages/core/agent-controller/README.zh.md) 的 `ctx.agentController.deliver`,而不是 `followup()` 或 `steer()`。控制器按该 Agent 的投递顺序准备内容,向 `agent-controller/admission` waterfall 请求决定,再放入被接纳的消息;Agent 自身机制产生的消息不经过这个阶段。[投递决策](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md)说明了这个阶段为什么不放在 `agent` 里。
源码:[`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
监听器返回 `next()` 的决定即放行,在 `next()` 之前 await 即暂留消息,返回 `reject` 决定即拒绝:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
<a id="interception-decisions"></a>
## 拦截决策
@@ -511,33 +449,6 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.zh.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1200,43 +1111,6 @@ Types: [Scoped](scope.zh.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+2 -6
View File
@@ -930,14 +930,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* Admit one prompt after explicitly resuming its Session.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @param signal - caller cancellation before prompt admission begins.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
+2 -6
View File
@@ -934,14 +934,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* Admit one prompt after explicitly resuming its Session.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @param signal - caller cancellation before prompt admission begins.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: fe242dda6579245e
zh: cbd8c012135623de
en: 8fcfa14545bd9d1c
zh: c10c2bba792699ef
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
+1 -1
View File
@@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. A prompt enters the Agent through [`ctx.agentController.deliver`](../../core/agent-controller/README.md#use-this-package), which runs the receipt resolution and attachment storage in the Agent's delivery order and binds the receipts immediately before the message enters the inbox, so the call stays open while an `agent-controller/admission` listener holds it. An image prompt takes the Session's image admission turn when its preparation starts and keeps it until the prompt settles, so a model selection lands either before its image check or after it entered the inbox, and waits while a listener holds that image prompt. A refusal returns `session/prompt-rejected` with the listener's `reason` and `presented`, a listener failure returns the same code with reason `agent-controller/admission-failed`, and an Agent disposed meanwhile returns `session/not-found`. A client that predates `session/prompt-rejected` shows it as an ordinary failure with its message and code. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
+1 -1
View File
@@ -33,7 +33,7 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。提示词经 [`ctx.agentController.deliver`](../../core/agent-controller/README.zh.md#use-this-package) 进入 Agent:控制器按该 Agent 的投递顺序完成凭证解析与附件存储,并在消息进入收件箱前一刻绑定凭证,因此 `agent-controller/admission` 监听器暂留它期间调用保持未完成。图片提示词在开始准备时取得该 Session 的图片准入顺位,并持有到提示词结束,因此模型切换要么发生在它的图片检查之前,要么发生在它进入收件箱之后;监听器暂留这条图片提示词期间,模型切换会等待。被拒绝时返回 `session/prompt-rejected`,并带上监听器给出的 `reason` 与 `presented`;监听器失败时返回同一错误码,reason 为 `agent-controller/admission-failed`;期间 Agent 被销毁则返回 `session/not-found`。早于 `session/prompt-rejected` 的旧客户端把它显示为普通失败,带消息与错误码。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
@@ -80,7 +80,6 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
"@deepseek-ai/dsh-api-gateway": "workspace:*",
@@ -126,7 +125,6 @@
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
@@ -387,27 +387,6 @@ export class ApiSessionAgentController {
return result
}
/**
* Take the image admission turn for one Agent: wait until earlier image
* admissions and model selections settled, then hold the turn until the
* returned release runs.
* @param agent - live Agent that owns the serialization chain.
* @param signal - the waiting caller's cancellation; a caller cancelled by its turn passes the turn on.
* @returns the release, which the caller runs once its image prompt settled.
* @throws the cancellation reason when the caller was cancelled by its turn.
*/
acquireImageAdmission(agent: Agent, signal: AbortSignal): Promise<() => void> {
const previous = this.imageAdmissionChains.get(agent) ?? Promise.resolve()
const held = Promise.withResolvers<undefined>()
this.imageAdmissionChains.set(agent, previous.then(() => held.promise))
const release = (): void => { held.resolve(undefined) }
return previous.then(() => {
if (signal.aborted) release()
signal.throwIfAborted()
return release
})
}
/**
* Resolve the preset id and pre-publication Agent setup for a create or resume.
* @param presetId - requested preset or the configured default when omitted.
+42 -89
View File
@@ -5,15 +5,14 @@ import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import type { ADMISSION_AGENT_GONE, ADMISSION_REJECTED_CODE, AgentAdmissionRejected } from '@deepseek-ai/dsh-agent-controller'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type {
AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,
} from '@deepseek-ai/dsh-attachment'
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
import type { PromptFileBinding } from '@deepseek-ai/dsh-client-file-upload'
import type {} from '@deepseek-ai/dsh-client-file-upload'
import {
ReasoningEffortId, assistantStreamChunks, freezeMessage,
ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { buildForkSeed } from '@deepseek-ai/dsh-session/fork'
@@ -311,72 +310,56 @@ export class SessionCommandController {
}
/**
* Deliver one prompt through the Agent controller, whose admission stage
* may hold or refuse it; the Remote method has already validated the
* content and built the durable message source. The controller runs this
* Session's content preparation (image model check, upload receipts,
* attachment storage) in the Agent's delivery order and binds the upload
* receipts immediately before the message enters the inbox. A repeated
* request identity is acknowledged without delivering it again. An image
* prompt takes the Session's image admission turn when its preparation
* starts and keeps it until the prompt settles, so a model selection lands
* either before its image check or after it entered the inbox; a selection
* waits while a listener holds that image prompt.
* Admit one prompt after Agent and attachment validation; the Remote method
* has already validated the content and built the durable message source.
* @param request - Session identity, prompt content, and delivery mode.
* @param source - the `user-rpc` source recorded on the accepted message.
* @param signal - the requesting client's cancellation; it stops a prompt that waits for its turn or is held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
async prompt(request: SessionPromptRequest, source: MessageSource, signal: AbortSignal): Promise<SessionPromptValue> {
async prompt(request: SessionPromptRequest, source: MessageSource): Promise<SessionPromptValue> {
const agent = await this.resolveAgent(request.sessionId)
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
const hasImage = request.content.some(part => part.type === 'image')
const uploads: { receiptIds: readonly FileUploadReceiptId[]; binding?: PromptFileBinding } = { receiptIds: [] }
const imageTurn: { release?: () => void } = {}
try {
await this.ctx.agentController.deliver(agent, {
mode: request.mode === 'steer' ? 'steer' : 'followup',
source,
signal,
prepare: async (preparing) => {
if (hasImage) {
// Taken in the Agent's delivery order, so a waiting image prompt keeps its place among the others.
imageTurn.release = await this.agents.acquireImageAdmission(agent, preparing)
await this.requireImageInput(agent)
const admit = async (): Promise<SessionPromptValue> => {
try {
if (hasImage) {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
throw new RemoteError(
'session/attachment-invalid',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
}
const admission = resolvePromptFileReceipts(
request.content,
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
}
const admission = resolvePromptFileReceipts(
request.content,
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
)
const content = await this.ctx.attachments.admitPromptContent(admission.content)
const message: UserMessage = createUserMessage({ content, source })
if (this.ctx.agents.get(agent.id) !== agent) {
throw new RemoteError(
'session/not-found',
`session "${agent.id}" was disposed during prompt admission`,
{ sessionId: agent.id },
)
uploads.receiptIds = admission.receiptIds
return await this.ctx.attachments.admitPromptContent(admission.content)
},
beforeInsert: () => {
uploads.binding = this.ctx.fileUploads.bindPrompt(agent, uploads.receiptIds, request.requestId)
},
})
uploads.binding?.commit()
} catch (error) {
throw promptFailure(agent, error, signal)
} finally {
uploads.binding?.[Symbol.dispose]()
imageTurn.release?.()
}
return { accepted: true }
}
/** Refuse an image prompt when the Session's current model does not declare image input. */
private async requireImageInput(agent: Agent): Promise<void> {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
throw new RemoteError(
'session/attachment-invalid',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
}
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
binding.commit()
} catch (error) {
if (remoteErrorOf(error) !== undefined) throw error
if (error instanceof AttachmentError) {
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
}
return { accepted: true }
}
return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
}
private async requireModel(selection: Pick<AgentModelSelection, 'provider' | 'model'>): Promise<void> {
@@ -577,36 +560,6 @@ export class SessionCommandController {
}
}
// Typed by the agent-controller constants, so a changed value fails this module's build while it imports no agent-controller value.
const ADMISSION_REJECTED: typeof ADMISSION_REJECTED_CODE = 'AGENT_ADMISSION_REJECTED'
const AGENT_GONE: typeof ADMISSION_AGENT_GONE = 'agent-controller/agent-gone'
/** Recognize an admission refusal by the code that the error of every copy of the agent-controller module carries. */
function isAdmissionRejected(error: unknown): error is AgentAdmissionRejected {
return error instanceof Error && 'code' in error && error.code === ADMISSION_REJECTED
}
/**
* Map a failed prompt admission onto the Session Remote's failures: Remote
* failures and the client's own cancellation pass unchanged, and an admission
* refusal keeps its reason and whether its plugin presents it.
*/
function promptFailure(agent: Agent, error: unknown, signal: AbortSignal): unknown {
if (remoteErrorOf(error) !== undefined || signal.aborted) return error
if (isAdmissionRejected(error)) {
if (error.reason === AGENT_GONE) {
return new RemoteError('session/not-found', `session "${agent.id}" was disposed during prompt admission`, {
sessionId: agent.id,
})
}
return new RemoteError('session/prompt-rejected', error.message, { reason: error.reason, presented: error.presented })
}
if (error instanceof AttachmentError) {
return new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
return new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
}
function resolvePromptFileReceipts(
content: SessionPromptRequest['content'],
stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
+3 -8
View File
@@ -117,7 +117,6 @@ export interface SessionControllerInternals {
/** Host service backing the generated `ctx.remote.session` namespace. */
export class SessionController extends TypertRemoteService {
static inject = [
'agentController',
'agentDefaultModel',
'agents',
'attachments',
@@ -459,14 +458,10 @@ export class SessionController extends TypertRemoteService {
}
/**
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* Admit one prompt after explicitly resuming its Session.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @param signal - caller cancellation before prompt admission begins.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt')
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
@@ -492,7 +487,7 @@ export class SessionController extends TypertRemoteService {
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
...(principal === undefined ? {} : { principal }),
}
return this.commands.prompt(request, source, signal)
return this.commands.prompt(request, source)
}
/**
@@ -212,12 +212,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'session/projections-unavailable': Record<string, never>
'session/writer-held': { readonly sessionId: SessionId }
'session/agent-busy': { readonly reason: string }
'session/prompt-rejected': {
/** Code of the refusal, owned by the plugin that refused, such as `forgePanel/worktree-missing`. */
readonly reason: string
/** Whether the refusing plugin shows its own explanation, so the client adds no notice. */
readonly presented: boolean
}
'session/invalid-time-zone': { readonly value: string }
'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
'agent-preset/conflict': {
@@ -1,6 +1,5 @@
import { Context, FiberState } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentController from '@deepseek-ai/dsh-agent-controller'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
@@ -39,7 +38,6 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentController)
await ctx.plugin(CommandRuntime)
const session = ctx.sessions.create(SESSION, {
meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
@@ -101,8 +99,6 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
selectionFor: () => selection,
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
} as unknown as ApiSessionAgentController
// Image prompts take their admission turn at once; no model selection competes in these tests.
Object.assign(agents, { acquireImageAdmission: () => Promise.resolve(() => {}) })
const uploads = new FileUploads(ctx)
if (uploadRoute === undefined) throw new Error('file upload route was not registered')
return {
@@ -121,8 +117,6 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
/** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */
const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId }
/** A client that stays connected for the whole prompt. */
const SIGNAL = new AbortController().signal
function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
return {
@@ -174,7 +168,7 @@ describe('Session file uploads', () => {
{ type: 'file', receiptId: receipt.receiptId },
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
{ type: 'text', text: 'read it' },
]), USER_SOURCE, SIGNAL)
]), USER_SOURCE)
expect(followup).toHaveBeenCalledTimes(1)
const message = followup.mock.calls[0]?.[0] as UserMessage
expect(message.content).toEqual([
@@ -201,7 +195,7 @@ describe('Session file uploads', () => {
name: 'huge.bin',
}))
expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{ type: 'file', attachment: receipt.file },
])
@@ -228,7 +222,7 @@ describe('Session file uploads', () => {
await expect(controller.prompt(promptRequest([
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
{ type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
expect(followup).not.toHaveBeenCalled()
expect(saveImages).not.toHaveBeenCalled()
})
@@ -326,7 +320,7 @@ describe('Session file uploads', () => {
const { controller, uploads, agent } = await uploadHarness()
uploads.retirePrompt(agent, 'not-staged')
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
uploads.retirePrompt(agent, 'other-request')
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
@@ -341,7 +335,7 @@ describe('Session file uploads', () => {
{ data: 'AAAA' },
new AbortController().signal,
)
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE, SIGNAL)
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE)
first.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'extension event' }],
source: { kind: 'user', rpcId: 1 } as never,
@@ -366,9 +360,9 @@ describe('Session file uploads', () => {
it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
const { controller, agent, followup } = await uploadHarness()
const request = promptRequest([{ type: 'text', text: 'once' }])
await controller.prompt(request, USER_SOURCE, SIGNAL)
await controller.prompt(request, USER_SOURCE)
agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true })
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
expect(followup).toHaveBeenCalledOnce()
})
@@ -385,7 +379,7 @@ describe('Session file uploads', () => {
source: { kind: 'user', rpcId: request.requestId },
}), { surfaceOp: 'append' })
await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true })
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
expect(followup).not.toHaveBeenCalled()
})
@@ -395,7 +389,7 @@ describe('Session file uploads', () => {
saveImages.mockReturnValueOnce(admitted.promise)
const prompting = controller.prompt(promptRequest([
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
]), USER_SOURCE, SIGNAL)
]), USER_SOURCE)
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
await disposeAgent()
admitted.resolve([{
@@ -407,7 +401,7 @@ describe('Session file uploads', () => {
it('rejects when a previously bound receipt retires during image admission', async () => {
const { controller, uploads, agent, saveImages } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
saveImages.mockReturnValueOnce(admitted.promise)
const prompting = controller.prompt({
@@ -416,7 +410,7 @@ describe('Session file uploads', () => {
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
]),
requestId: 'req-2' as SessionRequestId,
}, USER_SOURCE, SIGNAL)
}, USER_SOURCE)
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
uploads.retirePrompt(agent, 'req-1')
admitted.resolve([{
@@ -430,12 +424,12 @@ describe('Session file uploads', () => {
it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
const { controller, uploads, agent, followup } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
followup.mockImplementationOnce(() => { throw new Error('busy') })
await expect(controller.prompt({
...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
requestId: 'req-2' as SessionRequestId,
}, USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' })
}, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
uploads.retirePrompt(agent, 'req-1')
expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
})
@@ -446,7 +440,7 @@ describe('Session file uploads', () => {
followup.mockImplementationOnce(() => { throw new Error('busy') })
await expect(controller.prompt(promptRequest([
{ type: 'file', receiptId: receipt.receiptId },
]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' })
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
uploads.retirePrompt(agent, 'req-1')
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
})
@@ -454,7 +448,7 @@ describe('Session file uploads', () => {
it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
const { controller, uploads, agent, followup } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
const queued = followup.mock.calls[0]?.[0] as UserMessage
agent.inbox.append('next-turn', queued)
expect(await controller.updateQueue({
@@ -475,7 +469,7 @@ describe('Session file uploads', () => {
await controller.prompt(promptRequest([
{ type: 'file', receiptId: first.receiptId },
{ type: 'file', receiptId: second.receiptId },
]), USER_SOURCE, SIGNAL)
]), USER_SOURCE)
const message = followup.mock.calls[0]?.[0] as UserMessage
expect(message.content).toEqual([
@@ -1,217 +0,0 @@
/**
* Session prompts through the Agent controller's admission stage: how the
* `session/prompt` Remote reports a held, refused, or failed admission to the
* client.
*/
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { createSessionTestRemote } from './test-remote.ts'
let nextRequestId = 1
async function harness(options: { readonly refusal?: Error } = {}) {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create()
const inbox = createInboxStub()
const deliver = (target: 'next-turn' | 'next-step', message: UserMessage): void => {
if (options.refusal !== undefined) throw options.refusal
inbox.append(target, message)
}
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
status: 'idle',
ctx,
send: () => {},
followup: (message) => { deliver('next-turn', message) },
steer: (message) => { deliver('next-step', message) },
inject: () => {},
cancel: () => {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
const unregister = await ctx.agents.register(agent)
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
cwd: '/tmp',
})
const prompt = (text: string, mode: SessionPromptRequest['mode'] = 'queue', requestId?: SessionRequestId) => ({
requestId: requestId ?? `admission-${String(nextRequestId++)}` as SessionRequestId,
sessionId: session.id,
mode,
content: [{ type: 'text' as const, text }],
})
return { ctx, agent, inbox, remote, prompt, unregister }
}
function textsOf(messages: readonly UserMessage[]): string[] {
return messages.map(message => message.content.map(block => block.type === 'text' ? block.text : '').join(''))
}
describe('Session prompt admission', () => {
it('admits a queued prompt into the next turn and a steered one into the next step', async () => {
const { ctx, agent, inbox, remote, prompt } = await harness()
const admitted: Agent[] = []
ctx.on('agent-controller/admission', async ({ agent: subject }, next) => {
admitted.push(subject)
return await next()
})
expect(await remote.prompt(prompt('later'))).toEqual({ ok: true, value: { accepted: true } })
expect(await remote.prompt(prompt('now', 'steer'))).toEqual({ ok: true, value: { accepted: true } })
expect(admitted).toEqual([agent, agent])
expect(textsOf(inbox.nextTurn)).toEqual(['later'])
expect(textsOf(inbox.nextStep)).toEqual(['now'])
})
it('keeps the prompt call open while a listener holds it', async () => {
const { ctx, inbox, remote, prompt } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
let settled = false
const pending = remote.prompt(prompt('held')).finally(() => { settled = true })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(settled).toBe(false)
expect(inbox.nextTurn).toEqual([])
release.resolve(undefined)
expect(await pending).toEqual({ ok: true, value: { accepted: true } })
expect(textsOf(inbox.nextTurn)).toEqual(['held'])
})
it('returns a refusal with its reason and whether its plugin presents it, and keeps the inbox untouched', async () => {
const { ctx, inbox, remote, prompt } = await harness()
let presented = true
ctx.on('agent-controller/admission', () => Promise.resolve({
kind: 'reject' as const, reason: 'fixture/worktree-missing', message: 'The worktree was removed.', presented,
}))
expect(await remote.prompt(prompt('refused'))).toMatchObject({
ok: false,
error: {
code: 'session/prompt-rejected',
message: 'The worktree was removed.',
details: { reason: 'fixture/worktree-missing', presented: true },
},
})
presented = false
expect(await remote.prompt(prompt('refused again'))).toMatchObject({
ok: false,
error: { code: 'session/prompt-rejected', details: { reason: 'fixture/worktree-missing', presented: false } },
})
expect(inbox.nextTurn).toEqual([])
})
it('reports a failed listener as a refusal the client explains', async () => {
const { ctx, inbox, remote, prompt } = await harness()
ctx.on('agent-controller/admission', () => Promise.reject(new Error('listener broke')))
const result = await remote.prompt(prompt('broken'))
expect(result).toMatchObject({
ok: false,
error: { code: 'session/prompt-rejected', details: { reason: 'agent-controller/admission-failed', presented: false } },
})
expect(result.ok ? '' : result.error.message).toContain('listener broke')
expect(inbox.nextTurn).toEqual([])
})
it('cancels a held prompt whose client went away', async () => {
const { ctx, inbox, remote, prompt } = await harness()
ctx.on('agent-controller/admission', async ({ signal }, next) => {
await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
return await next()
})
const client = new AbortController()
const pending = remote.prompt(prompt('abandoned'), client.signal)
await new Promise<void>((resolve) => { setImmediate(resolve) })
client.abort(new Error('client left'))
expect(await pending).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
expect(inbox.nextTurn).toEqual([])
})
it('reports a Session whose Agent was disposed while its prompt was held as not found', async () => {
const { ctx, agent, remote, prompt, unregister } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
const pending = remote.prompt(prompt('orphaned'))
await new Promise<void>((resolve) => { setImmediate(resolve) })
await unregister()
release.resolve(undefined)
expect(await pending).toMatchObject({
ok: false,
error: { code: 'session/not-found', details: { sessionId: agent.id } },
})
})
it('maps a refusal from another copy of the agent-controller module by its code', async () => {
const { ctx, agent, remote, prompt } = await harness()
// Another copy of the module raises the same fields from its own class.
const refusal = (reason: string): Error => Object.assign(new Error('The worktree was removed.'), {
name: 'AgentAdmissionRejected', code: 'AGENT_ADMISSION_REJECTED', reason, presented: true,
})
vi.spyOn(ctx.agentController, 'deliver')
.mockRejectedValueOnce(refusal('fixture/worktree-missing'))
.mockRejectedValueOnce(refusal('agent-controller/agent-gone'))
expect(await remote.prompt(prompt('refused'))).toMatchObject({
ok: false,
error: {
code: 'session/prompt-rejected',
message: 'The worktree was removed.',
details: { reason: 'fixture/worktree-missing', presented: true },
},
})
expect(await remote.prompt(prompt('orphaned'))).toMatchObject({
ok: false,
error: { code: 'session/not-found', details: { sessionId: agent.id } },
})
})
it('acknowledges a repeated request without admitting it again', async () => {
const { ctx, inbox, remote, prompt } = await harness()
let admissions = 0
ctx.on('agent-controller/admission', async (_admission, next) => {
admissions += 1
return await next()
})
const first = prompt('once')
expect(await remote.prompt(first)).toEqual({ ok: true, value: { accepted: true } })
expect(await remote.prompt(prompt('once', 'queue', first.requestId))).toEqual({ ok: true, value: { accepted: true } })
expect(admissions).toBe(1)
expect(textsOf(inbox.nextTurn)).toEqual(['once'])
})
it('reports the Agent refusing to queue a prompt as busy', async () => {
const { remote, prompt } = await harness({ refusal: new Error('inbox closed') })
expect(await remote.prompt(prompt('rejected'))).toMatchObject({
ok: false,
error: { code: 'session/agent-busy', details: { reason: 'Error: inbox closed' } },
})
})
})
@@ -137,46 +137,6 @@ async function harness(logged?: {
return { ctx, agent, sessionId: session.id }
}
/** An image store whose saved attachments are one-pixel PNGs. */
function provideImageStore(ctx: Context): void {
const attachments = {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
maxImageDimension: 2000,
mediaTypes: ['image/png'],
},
validateImage: () => Promise.resolve(),
saveImage: (input: { data: Uint8Array; mediaType: 'image/png' }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`, mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1,
}),
}
ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
}
/** A queued prompt with its text, after a one-pixel image when `withImage` is set. */
function imagePrompt(sessionId: SessionId, text: string, withImage: boolean): SessionPromptRequest {
return promptRequest({
sessionId,
mode: 'queue',
content: [
...withImage ? [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' }] : [],
{ type: 'text' as const, text },
],
})
}
function textOf(message: UserMessage): string {
return message.content.map(block => block.type === 'text' ? block.text : '').join('')
}
/** Let pending prompts run as far as they can. */
async function settle(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
function expectValue<T>(result: { ok: true; value: T } | { ok: false }): T {
if (!result.ok) throw new Error('expected successful response')
return result.value
@@ -353,110 +313,6 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('admits image prompts in arrival order behind an image prompt that a listener holds', async () => {
const { ctx, agent, sessionId } = await harness()
provideImageStore(ctx)
const followed: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } })
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async ({ message }, next) => {
if (textOf(message) === 'held') await release.promise
return await next()
})
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const prompts = [
remote.prompt(imagePrompt(sessionId, 'held', true)),
remote.prompt(imagePrompt(sessionId, 'second image', true)),
remote.prompt(imagePrompt(sessionId, 'third text', false)),
]
await settle()
expect(followed).toEqual([])
release.resolve(undefined)
expect((await Promise.all(prompts)).map(result => result.ok)).toEqual([true, true, true])
expect(followed).toEqual(['held', 'second image', 'third text'])
await ctx.fiber.dispose()
})
it('keeps a model selection behind the image prompt being admitted but not behind one still queued', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
provideImageStore(ctx)
const events: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { events.push(`admitted ${textOf(message)}`) } })
const releases = new Map([['held image', Promise.withResolvers<undefined>()], ['held text', Promise.withResolvers<undefined>()]])
ctx.on('agent-controller/admission', async ({ message }, next) => {
await releases.get(textOf(message))?.promise
return await next()
})
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const select = () => remote.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
.then((result) => { events.push('selected'); return result })
// A held image prompt was checked against the current model, so the selection waits for its append.
const heldImage = remote.prompt(imagePrompt(sessionId, 'held image', true))
await settle()
const first = select()
await settle()
expect(events).toEqual([])
releases.get('held image')?.resolve(undefined)
expect((await heldImage).ok).toBe(true)
expect((await first).ok).toBe(true)
expect(events).toEqual(['admitted held image', 'selected'])
// An image prompt queued behind a held text prompt has not been checked yet, so the selection does not wait for it.
const heldText = remote.prompt(imagePrompt(sessionId, 'held text', false))
const queuedImage = remote.prompt(imagePrompt(sessionId, 'queued image', true))
await settle()
expect((await select()).ok).toBe(true)
expect(events).toEqual(['admitted held image', 'selected', 'selected'])
releases.get('held text')?.resolve(undefined)
expect((await heldText).ok).toBe(true)
expect(await queuedImage).toMatchObject({
ok: false, error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
})
expect(events).toEqual(['admitted held image', 'selected', 'selected', 'admitted held text'])
await ctx.fiber.dispose()
})
it('passes the image admission turn on when the sender of a waiting image prompt leaves', async () => {
const { ctx, agent, sessionId } = await harness()
provideImageStore(ctx)
const followed: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } })
const listing = Promise.withResolvers<readonly LlmModelInfo[]>()
ctx.llm.registerAdapter(['gated'], new class extends CatalogAdapter {
override listModels(): Promise<readonly LlmModelInfo[]> {
return listing.promise
}
}('Gated', []))
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
// The selection holds the image admission turn while the provider lists its models.
const selection = remote.selectModel(request({ sessionId, provider: 'gated', model: 'slow' }))
await settle()
const sender = new AbortController()
const leaving = remote.prompt(imagePrompt(sessionId, 'left', true), sender.signal)
await settle()
sender.abort(new Error('client left'))
expect(await leaving).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
listing.resolve([{ provider: 'gated', id: 'slow', name: 'Slow' }])
expect((await selection).ok).toBe(true)
expect((await remote.prompt(imagePrompt(sessionId, 'after', true))).ok).toBe(true)
expect(followed).toEqual(['after'])
await ctx.fiber.dispose()
})
it('authorizes attachment bytes only when the session event stream references the id', async () => {
const { ctx, agent, sessionId } = await harness()
const ref = {
@@ -31,7 +31,6 @@ import {
remoteErrorOf,
type RemoteResult,
} from '@deepseek-ai/dsh-typert-protocol'
import AgentController from '@deepseek-ai/dsh-agent-controller'
import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
import SessionController from '../src/index.ts'
import type {
@@ -294,7 +293,6 @@ async function installControllers(
} as never)
}
installSessionReadTestServices(ctx)
if (ctx.get('agentController') === undefined) new AgentController(ctx)
if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx)
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
class TestController extends SessionController {
@@ -32,9 +32,6 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-controller"
},
{
"path": "../../core/agent-default-model"
},
-5
View File
@@ -82,11 +82,6 @@
- id: agent
name: '@deepseek-ai/dsh-agent'
# Delivers messages from outside an Agent into its inbox after the admission
# stage; entry points such as the Web Session Remote deliver through it.
- id: agent-controller
name: '@deepseek-ai/dsh-agent-controller'
- id: plugin-package-inventory-deepseek
name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
-1
View File
@@ -37,7 +37,6 @@
"@deepseek-ai/dsh-hmr": "workspace:*",
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-attachment-local": "workspace:*",
+2 -2
View File
@@ -15,8 +15,8 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/packages-core/packages:
en: 028100b906be530b
zh: d990cc2806b4240d
en: 083b59ee474230e8
zh: f11c7bc21bbcdbe6
/packages-core/related-documentation:
en: dfe5990edf146a91
zh: b64e4b2d812286d4
+1 -2
View File
@@ -30,11 +30,10 @@ Use the core packages to build or extend an agent that records durable session h
| [`tools/`](tools/README.md) | The tool registry and guarded execution pipeline the loop dispatches through | `ctx.tools` |
| [`agent-tool-presentation/`](agent-tool-presentation/README.md) | Per-agent tool-presentation selector for presets | no ctx key |
| [`agent/`](agent/README.md) | The `Agent` handle plugins program against, plus its live registry and events | `ctx.agents` |
| [`agent-controller/`](agent-controller/README.md) | Delivery of messages from outside an Agent into its inbox after the admission stage | `ctx.agentController` |
| [`agent-default-model/`](agent-default-model/README.md) | The deployment default model selection entry points apply to fresh agents | `ctx.agentDefaultModel` |
| [`agent-loop/`](agent-loop/README.md) | The default agent driver: creates agents and runs the turn and step lifecycle | `ctx.agentLoop` |
`scope` supplies the shared scoping primitive; `agent` owns the public `Agent` contract, while `agent-loop` is its default implementation, so extension plugins depend on `agent` and the driver stays swappable. `agent-controller` owns how a sender's message enters an inbox, including the admission stage that plugins use to hold or refuse it. `agent-default-model` owns the deployment selection an entry point applies when a session has none of its own. Runnable compositions live under [`packages/bundle`](../bundle/README.md); this group owns only the swappable spine pieces.
`scope` supplies the shared scoping primitive; `agent` owns the public `Agent` contract, while `agent-loop` is its default implementation, so extension plugins depend on `agent` and the driver stays swappable. `agent-default-model` owns the deployment selection an entry point applies when a session has none of its own. Runnable compositions live under [`packages/bundle`](../bundle/README.md); this group owns only the swappable spine pieces.
-----
+1 -2
View File
@@ -30,11 +30,10 @@ kind: "package-group"
| [`tools/`](tools/README.zh.md) | 供循环分发使用的工具注册表与带防护机制的执行流水线 | `ctx.tools` |
| [`agent-tool-presentation/`](agent-tool-presentation/README.zh.md) | 为 preset 提供按 agent 的工具呈现方式选择器 | 无 ctx key |
| [`agent/`](agent/README.zh.md) | 供插件编程使用的 `Agent` 句柄,以及其实时注册表与事件 | `ctx.agents` |
| [`agent-controller/`](agent-controller/README.zh.md) | 经接纳阶段把来自 Agent 外部的消息投递进收件箱 | `ctx.agentController` |
| [`agent-default-model/`](agent-default-model/README.zh.md) | 入口对全新 agent 应用的部署默认模型选择 | `ctx.agentDefaultModel` |
| [`agent-loop/`](agent-loop/README.zh.md) | 默认 agent 驱动器:创建 agent 并运行轮次与步骤生命周期 | `ctx.agentLoop` |
`scope` 提供共享作用域原语;`agent` 负责公开的 `Agent` 约定,而 `agent-loop` 是其默认实现,因此扩展插件依赖 `agent`,驱动器保持可替换。`agent-controller` 负责发送方的消息如何进入收件箱,包括插件用来暂留或拒绝消息的接纳阶段。`agent-default-model` 负责入口在会话自身没有选择时应用的部署选择。可运行组合位于 [`packages/bundle`](../bundle/README.zh.md);本分组只负责可替换的主干组件。
`scope` 提供共享作用域原语;`agent` 负责公开的 `Agent` 约定,而 `agent-loop` 是其默认实现,因此扩展插件依赖 `agent`,驱动器保持可替换。`agent-default-model` 负责入口在会话自身没有选择时应用的部署选择。可运行组合位于 [`packages/bundle`](../bundle/README.zh.md);本分组只负责可替换的主干组件。
-----
@@ -1,49 +0,0 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-controller/README.md
/:
en: 4ce07f9999659fff
zh: 4511db86e543aebf
/deepseek-ai-dsh-agent-controller:
en: d0e0dc83964706b1
zh: a50fc815c4096c3b
/deepseek-ai-dsh-agent-controller/summary:
en: 9736d739cd1255b3
zh: d7badb4d9dee9449
/deepseek-ai-dsh-agent-controller/table-of-contents:
en: d152484eb41ac6b4
zh: f14f34fce35a8d90
/deepseek-ai-dsh-agent-controller/use-this-package:
en: 9fc767473d82e29c
zh: c9ba0267b0661af7
/deepseek-ai-dsh-agent-controller/use-this-package/deliver-a-message:
en: bb45e0cfa5e32d13
zh: 805082d8dd2e1e97
/deepseek-ai-dsh-agent-controller/use-this-package/hold-or-refuse-a-message:
en: 7c453bf4191f2bd5
zh: c58cd73e925f0138
/deepseek-ai-dsh-agent-controller/understand-the-implementation:
en: 592af7e68423bd1c
zh: d7640d838b8bda1f
/deepseek-ai-dsh-agent-controller/understand-the-implementation/design-concept:
en: 839265471948929a
zh: 5409775cee9833a8
/deepseek-ai-dsh-agent-controller/understand-the-implementation/source-map:
en: e1477bb7c64b032b
zh: 319f59d79fdcc4f9
/deepseek-ai-dsh-agent-controller/further-exploration:
en: e2b9d38dc3bc51a0
zh: 7fec4a6bc4ed37cd
/deepseek-ai-dsh-agent-controller/model-experience:
en: 845cdb9c119a58f5
zh: 83ba232d8ea1b463
/deepseek-ai-dsh-agent-controller/model-experience/kv-cache-effect:
en: aa1853393a68d5fb
zh: 19383b52e8f75af5
/deepseek-ai-dsh-agent-controller/known-limitations-and-deferred-work:
en: 7587c7c8a3968ba5
zh: 591171acc60df510
/deepseek-ai-dsh-agent-controller/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
-123
View File
@@ -1,123 +0,0 @@
---
description: "Delivery of messages from outside an Agent into its inbox, for maintainers of entry points such as the Web Session Remote and authors of admission listeners."
kind: "package-reference"
---
# @deepseek-ai/dsh-agent-controller
English | [中文](README.zh.md)
## Summary
Deliver a message that a sender brings on someone's behalf into an Agent's inbox after the `agent-controller/admission` stage decided it. Listeners can hold a message until the Agent is ready or refuse it with a reason the sender reports; messages that the Agent's own machinery produces do not pass the stage.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
The base bundle mounts the service as `ctx.agentController`; it has no configuration. A sender that delivers work on someone's behalf resolves the receiving Agent itself and calls `deliver(agent, request)` instead of `followup()` or `steer()`.
### Deliver a message
`deliver()` runs `request.prepare(signal)` to turn the sender's content into durable message content, builds the message with `request.source`, asks the admission stage for a decision, calls `request.beforeInsert?.(message)`, and appends the message with `followup()` for `mode: 'followup'` or `steer()` for `mode: 'steer'`. It resolves with the message id once the message is in the inbox.
```text
const messageId = await ctx.agentController.deliver(agent, {
mode: 'followup',
source: { kind: 'user', rpcId },
signal,
prepare: () => ctx.attachments.admitPromptContent(parts),
beforeInsert: (message) => { record(message.id) },
})
```
`prepare` runs after the earlier messages to the same Agent settled, so content admission happens in arrival order; its error reaches the sender unchanged. `beforeInsert` runs synchronously after admission, immediately before the message enters the inbox, for bookkeeping the sender needs before the Agent wakes. A throw from it keeps the message out of the inbox, and the sender reverts its own effects when `deliver()` rejects.
`deliver()` rejects with `AgentAdmissionRejected` when a listener refuses the message (with the listener's `reason` and `presented`), when a listener throws (`agent-controller/admission-failed`, with the error as `cause`), or when the Agent left the registry before its message entered the inbox (`agent-controller/agent-gone`). Each of these errors is a `HarnessError` whose `code` is `AGENT_ADMISSION_REJECTED` (`ADMISSION_REJECTED_CODE`); senders recognize a refusal by that code and its `reason`, not by the class, so the check also holds for an error from another copy of this package. A cancelled sender gets its cancellation reason at once, also while its message waits behind a held one, and errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through unchanged. The Web Session Remote maps these failures onto `session/prompt-rejected` and its other Session errors.
### Hold or refuse a message
Listeners of the `agent-controller/admission` waterfall run in registration order and return an `AdmissionDecision`. A listener admits by returning the decision of `next()`, whose last step is `{ kind: 'admit' }`; it holds the message by awaiting before `next()`, and refuses it by returning `{ kind: 'reject', reason, message, presented }` without calling `next()`. `reason` names the refusal, and `presented` says whether the refusing plugin explains it in its own interface. A listener throws only when its check itself fails, and it must not wait for a person when the message source is unattended, such as a schedule or a webhook. The `signal` in its payload aborts when the sender cancels or the controller stops; a holding listener must settle once it aborts.
```text
ctx.on('agent-controller/admission', async ({ agent, message, signal }, next) => {
if (!ready(agent)) return { kind: 'reject', reason: 'my-plugin/not-ready', message: 'Prepare the workspace first.', presented: false }
return await next()
})
```
The event is scope-filtered: a listener registered in an Agent's scoped context, such as one a preset mounts, receives only that Agent's messages. Exact signatures live in the generated region of the [core subsystem page](../../../docs/subsystems/core.md#cordis-surface).
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
This section explains how the service realizes the behavior above; the observable contract is covered in [Use this package](#use-this-package).
### Design concept
The controller chains each Agent's deliveries on one promise, so a held message keeps later messages to the same Agent waiting while other Agents proceed. The chain covers preparation, the decision, and the append, so messages enter the inbox in arrival order. Before preparing, and again after the stage returns, the controller checks cancellation and that the registry still holds the Agent; it then calls `beforeInsert` and `followup()` or `steer()` with no await in between. Listeners therefore never run after the message entered the inbox; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`. A message whose sender cancels while it waits keeps its place in the chain and is dropped when its turn comes, so the order of the others does not change. Disposing the controller aborts the signal that every pending delivery observes, so the messages that have not entered an inbox are rejected; disposal finishes only after every pending delivery, including its preparation and listeners, has settled.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | `AgentController`, the refusal code and fixed refusal reasons, and `AgentAdmissionRejected` |
| [`src/types.ts`](src/types.ts) | `DeliveryRequest`, `AdmissionDecision`, and the `agent-controller/admission` event |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
The package-level contract is enough for most senders and listeners; read these when you need the surrounding domain.
- [agent package](../agent/README.md) — the `Agent` handle, its inbox methods, and the `agent/*` events.
- [Session Controller](../../api/session-controller/README.md) — the Web `session/prompt` Remote that delivers through this package.
- [Agent Note: delivering outside messages through the Agent controller](../../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md) — why the admission stage lives here instead of in the Agent core.
- [Core group map](../README.md) — how the core packages compose.
-----
<a id="model-experience"></a>
## Model Experience
Indirectly, through the messages the controller appends to an Agent's inbox; the Agent loop owns how they enter a model request.
#### KV Cache effect
Admission adds no model-visible content and changes no request prefix; a refused message never reaches a request.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **Only the Web Session Remote delivers through the controller** — the SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts still call `followup()` or `steer()` directly, so their messages pass no admission listener.
- **Request deduplication stays with the sender** — the Web Session Remote acknowledges a retried `requestId` before calling `deliver()` by reading the Session history synchronously. Moving that check into the controller requires a projection of accepted request ids, because new synchronous history reads are prohibited.
- **A refused prompt can leave a stored attachment** — preparation stores attachments before the decision, so a refused or cancelled prompt can leave an unreferenced stored image or file.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
-123
View File
@@ -1,123 +0,0 @@
---
description: "把来自 Agent 外部的消息投递进其收件箱,面向 Web Session Remote 等入口的维护者和接纳监听器的作者。"
kind: "package-reference"
---
# @deepseek-ai/dsh-agent-controller
[English](README.md) | 中文
## 概述
发送方代表他人送来的消息,先由 `agent-controller/admission` 阶段做出决定,再投递进 Agent(智能体)的收件箱。监听器可以暂留消息直到 Agent 准备就绪,也可以带着发送方会报告的原因拒绝它;Agent 自身机制产生的消息不经过这个阶段。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [延伸阅读](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
base 组合包把这个服务挂载为 `ctx.agentController`,它没有配置项。代表他人送来工作的发送方自己解析接收的 Agent,然后调用 `deliver(agent, request)`,不再直接调用 `followup()` 或 `steer()`。
### 投递一条消息
`deliver()` 先运行 `request.prepare(signal)`,把发送方的内容转成持久的消息内容,再用 `request.source` 构造消息,向接纳阶段要一个决定,调用 `request.beforeInsert?.(message)`,最后在 `mode: 'followup'` 时经 `followup()`、在 `mode: 'steer'` 时经 `steer()` 放入消息。消息进入收件箱后,它返回该消息的 id。
```text
const messageId = await ctx.agentController.deliver(agent, {
mode: 'followup',
source: { kind: 'user', rpcId },
signal,
prepare: () => ctx.attachments.admitPromptContent(parts),
beforeInsert: (message) => { record(message.id) },
})
```
`prepare` 在发给同一 Agent 的更早消息结算之后才运行,因此内容准入按到达顺序进行;它的错误原样交给发送方。`beforeInsert` 在接纳之后同步运行,紧挨在消息进入收件箱之前,用于发送方在 Agent 被唤醒前必须完成的登记。它抛出异常时消息不会进入收件箱,`deliver()` 失败时由发送方撤销自己做过的改动。
以下情况 `deliver()` 抛出 `AgentAdmissionRejected`:监听器拒绝消息(带监听器给出的 `reason` 与 `presented`);监听器抛出异常(reason 为 `agent-controller/admission-failed`,`cause` 为原异常);消息进入收件箱之前 Agent 已离开注册表(reason 为 `agent-controller/agent-gone`)。这些错误都是 `HarnessError`,`code` 为 `AGENT_ADMISSION_REJECTED`(`ADMISSION_REJECTED_CODE`);发送方按这个 code 与 `reason` 识别拒绝,而不是按类判断,因此错误来自本包的另一份副本时判断同样成立。发送方取消时立即得到其取消原因,消息排在被暂留的消息之后时也是如此;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出。Web Session Remote 把这些失败映射为 `session/prompt-rejected` 及其他 Session 错误。
### 暂留或拒绝一条消息
`agent-controller/admission` waterfall 的监听器按注册顺序运行,返回 `AdmissionDecision`。监听器放行时返回 `next()` 的决定,链的最后一步是 `{ kind: 'admit' }`;在调用 `next()` 前 await 即暂留消息;不调用 `next()`、直接返回 `{ kind: 'reject', reason, message, presented }` 即拒绝它。`reason` 标明拒绝原因,`presented` 表示拒绝它的插件是否在自己的界面里给出说明。监听器只在自身的检查执行失败时抛出异常;消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理。载荷里的 `signal` 在发送方取消或控制器停止时中止;暂留消息的监听器必须在它中止后结束。
```text
ctx.on('agent-controller/admission', async ({ agent, message, signal }, next) => {
if (!ready(agent)) return { kind: 'reject', reason: 'my-plugin/not-ready', message: 'Prepare the workspace first.', presented: false }
return await next()
})
```
该事件按作用域过滤:注册在某个 Agent 作用域上下文里的监听器(例如 preset 挂载的监听器)只收到该 Agent 的消息。确切签名见 [core 子系统页](../../../docs/subsystems/core.zh.md#cordis-surface)的生成区块。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内部细节——点击展开</summary>
本节说明服务如何实现上面的行为;可观察的约定见[使用本包](#use-this-package)。
### 设计思路
控制器把每个 Agent 的投递串在同一个 promise 上,因此被暂留的消息会让同一 Agent 后续的消息等待,其他 Agent 不受影响。这条串行包住准备、决定和放入,所以消息按到达顺序进入收件箱。准备之前,以及接纳阶段返回之后,控制器都会检查是否已取消、注册表是否仍持有该 Agent;随后调用 `beforeInsert` 和 `followup()` 或 `steer()`,中间没有 await。因此监听器不会在消息进入收件箱之后运行;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。排队期间被发送方取消的消息保留它在串行中的位置,轮到它时被丢弃,其余消息的顺序不变。销毁控制器会中止所有待处理投递共同观察的信号,尚未进入收件箱的消息因此被拒绝;所有待处理投递(包括其准备和监听器)都结束之后,销毁才完成。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `AgentController`、拒绝 code 与固定的拒绝原因,以及 `AgentAdmissionRejected` |
| [`src/types.ts`](src/types.ts) | `DeliveryRequest`、`AdmissionDecision` 与 `agent-controller/admission` 事件 |
</details>
-----
<a id="further-exploration"></a>
## 延伸阅读
包级约定足以满足大多数发送方和监听器;需要了解周边领域时再阅读以下内容。
- [agent 包](../agent/README.zh.md)——`Agent` 句柄、它的收件箱方法与 `agent/*` 事件。
- [Session Controller](../../api/session-controller/README.zh.md)——经本包投递消息的 Web `session/prompt` Remote。
- [Agent Note:经 Agent 控制器投递外部消息](../../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md)——为什么接纳阶段放在这里,而不是 Agent 核心。
- [Core 分组地图](../README.zh.md)——core 各包如何组合。
-----
<a id="model-experience"></a>
## 模型体验
通过控制器放入 Agent 收件箱的消息间接影响;这些消息如何进入模型请求由 Agent 循环负责。
#### KV Cache 影响
接纳不增加任何模型可见内容,也不改变请求前缀;被拒绝的消息不会进入任何请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
- **目前只有 Web Session Remote 经控制器投递消息**:SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词仍直接调用 `followup()` 或 `steer()`,它们的消息不经过任何接纳监听器。
- **请求去重仍由发送方负责**:Web Session Remote 在调用 `deliver()` 之前同步读取 Session 历史,确认重试的 `requestId` 已被接收。把这项检查移进控制器需要一个记录已接收请求 id 的投影,因为仓库禁止新增同步历史读取。
- **被拒绝的提示词可能留下已存储的附件**:准备阶段在决定之前存储附件,因此被拒绝或取消的提示词可能留下无人引用的已存图片或文件。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文——点击展开</summary>
无。
</details>
@@ -1,6 +0,0 @@
{
"meta": {
"title": "Agent Controller",
"description": "Admits messages from outside an Agent into its inbox."
}
}
@@ -1,6 +0,0 @@
{
"meta": {
"title": "智能体消息接纳",
"description": "让外部发来的消息经过接纳阶段后进入智能体收件箱。"
}
}
@@ -1,45 +0,0 @@
{
"name": "@deepseek-ai/dsh-agent-controller",
"description": "Delivers messages from outside an Agent into its inbox through an admission stage",
"version": "0.2.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/core/agent-controller"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
},
"files": [
"locale/*.json",
"lib/index.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-scope": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-scope": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/cordis": "workspace:~"
}
}
-157
View File
@@ -1,157 +0,0 @@
/**
* Agent controller: the entry through which a sender delivers a message from
* outside an Agent into its inbox, after the `agent-controller/admission`
* stage decided it.
*
* @module @deepseek-ai/dsh-agent-controller
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import type { MessageId } from '@deepseek-ai/dsh-llm'
import type { AdmissionDecision, DeliveryRequest } from './types.ts'
export type * from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Delivery of messages from outside an Agent into its inbox. */
agentController: AgentController
}
}
/** `code` of every {@link AgentAdmissionRejected}; senders recognize a refusal by it. */
export const ADMISSION_REJECTED_CODE = 'AGENT_ADMISSION_REJECTED'
/** Refusal reason when an admission listener threw instead of returning a decision. */
export const ADMISSION_FAILED = 'agent-controller/admission-failed'
/** Refusal reason when the Agent left the registry before its message entered the inbox. */
export const ADMISSION_AGENT_GONE = 'agent-controller/agent-gone'
/** The last step of the admission stage, reached once every listener passed the message on. */
const ADMIT: AdmissionDecision = { kind: 'admit' }
/**
* A message `deliver` kept out of the inbox: a listener rejected it, a
* listener threw, or the Agent left the registry first. Senders recognize it
* by its {@link ADMISSION_REJECTED_CODE} `code` and read `reason`, never by
* its class, so the check also holds for an error from another copy of this
* module.
*/
export class AgentAdmissionRejected extends HarnessError {
declare readonly code: typeof ADMISSION_REJECTED_CODE
/**
* @param reason - why the message stayed out: a listener's reason, {@link ADMISSION_FAILED}, or {@link ADMISSION_AGENT_GONE}.
* @param message - explanation for a sender that shows nothing of the refusing plugin.
* @param presented - whether the refusing plugin explains the refusal in its own interface.
* @param options - standard Error options.
*/
constructor(readonly reason: string, message: string, readonly presented: boolean, options?: ErrorOptions) {
super(message, ADMISSION_REJECTED_CODE, options)
}
}
/**
* Follow a delivery until its sender cancels. The sender's wait ends with the
* cancellation reason at once; the delivery keeps its place in the Agent's
* order and stops at its next cancellation check, before any append.
*/
async function untilCancelled<T>(delivery: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted()
const cancelled = Promise.withResolvers<never>()
const onAbort = (): void => { cancelled.reject(signal.reason) }
signal.addEventListener('abort', onAbort, { once: true })
try {
return await Promise.race([delivery, cancelled.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/**
* Delivers the messages that senders, such as the Web Session Remote, bring
* on someone's behalf. Messages to one Agent are prepared, decided, and
* appended one at a time in arrival order; messages to different Agents do
* not wait for each other. Disposal stops every message that has not entered
* an inbox and finishes once each delivery it stopped has settled.
*/
export class AgentController extends Service {
static inject = ['agents']
/** Per Agent, the settlement of its latest delivery; the next message waits for it. */
private readonly deliveries = new WeakMap<Agent, Promise<void>>()
/** Settlements of the deliveries that have not finished; disposal waits for them. */
private readonly pending = new Set<Promise<void>>()
/** Aborted on disposal; stops the messages that have not entered an inbox. */
private readonly lifetime = new AbortController()
/** @param ctx - Host context carrying the Agent registry. */
constructor(ctx: Context) {
super(ctx, 'agentController')
ctx.effect(() => async () => {
this.lifetime.abort(new Error('The agent controller stopped before the message entered the inbox.'))
// Preparation and listeners observe the abort; the Agent registry stays available until they settle.
await Promise.all(this.pending)
}, 'agent-controller.lifetime')
}
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId> {
this.lifetime.signal.throwIfAborted()
const delivery = (this.deliveries.get(agent) ?? Promise.resolve()).then(() => this.run(agent, request))
const settled = delivery.then(() => undefined, () => undefined)
this.deliveries.set(agent, settled)
this.pending.add(settled)
void settled.then(() => { this.pending.delete(settled) })
return await untilCancelled(delivery, request.signal)
}
private async run(agent: Agent, request: DeliveryRequest): Promise<MessageId> {
const signal = AbortSignal.any([request.signal, this.lifetime.signal])
signal.throwIfAborted()
this.requireRegistered(agent)
const content = await request.prepare(signal)
signal.throwIfAborted()
const message = createUserMessage({ content, source: request.source })
let decision: AdmissionDecision
try {
decision = await agentEvents(this.ctx, agent)
.waterfall('agent-controller/admission', { message, signal }, () => Promise.resolve(ADMIT))
} catch (error: unknown) {
// A listener interrupted by cancellation reports the cancellation, not a failed check.
signal.throwIfAborted()
throw new AgentAdmissionRejected(ADMISSION_FAILED, `The message was not admitted: ${String(error)}`, false, { cause: error })
}
signal.throwIfAborted()
if (decision.kind === 'reject') throw new AgentAdmissionRejected(decision.reason, decision.message, decision.presented)
this.requireRegistered(agent)
request.beforeInsert?.(message)
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
return message.id
}
/** Keep a message out of an Agent that is no longer the registered one for its id. */
private requireRegistered(agent: Agent): void {
if (this.ctx.agents.get(agent.id) === agent) return
throw new AgentAdmissionRejected(ADMISSION_AGENT_GONE, `Agent "${agent.id}" left before its message entered the inbox.`, false)
}
}
export default AgentController
@@ -1,81 +0,0 @@
/**
* Delivery vocabulary of the Agent controller: the request a sender passes to
* `ctx.agentController.deliver`, the decision an admission listener returns,
* and the `agent-controller/admission` event.
*
* @module @deepseek-ai/dsh-agent-controller/types
*/
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm'
import type { Scoped } from '@deepseek-ai/dsh-scope'
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
export type DeliveryMode = 'followup' | 'steer'
/** One message a sender delivers on someone's behalf. */
export interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
/** The outcome of the admission stage for one message. */
export type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
}
}
@@ -1,454 +0,0 @@
/**
* Delivery from outside an Agent: `ctx.agentController.deliver` prepares the
* message content, asks the `agent-controller/admission` listeners for a
* decision, then appends an admitted message, one message per Agent at a time.
*/
import { describe, expect, it, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentController, { ADMISSION_AGENT_GONE, ADMISSION_FAILED, AgentAdmissionRejected } from '@deepseek-ai/dsh-agent-controller'
import type { DeliveryMode, DeliveryRequest } from '@deepseek-ai/dsh-agent-controller'
/** One message the Agent received through `followup` or `steer`. */
interface Delivery {
readonly mode: DeliveryMode
readonly text: string
readonly id: string
}
/** An inbox delivery never touches directly: messages enter through `followup` and `steer`. */
function untouchedInbox(): Inbox {
const refuse = (): never => {
throw new Error('delivery must enter through followup or steer')
}
return {
nextTurn: [], nextStep: [], clear: refuse, append: refuse, prepend: refuse, replace: refuse, remove: refuse, splice: refuse,
}
}
function textOf(message: UserMessage): string {
return message.content.map(block => block.type === 'text' ? block.text : '').join('')
}
/** A sender that stays connected. */
function connected(): AbortSignal {
return new AbortController().signal
}
/** A followup whose prepared content is one text block. */
function request(text: string, overrides: Partial<DeliveryRequest> = {}): DeliveryRequest {
return {
mode: 'followup',
source: { kind: 'user' },
signal: connected(),
prepare: () => Promise.resolve([{ type: 'text', text }]),
...overrides,
}
}
/** A preparation that records its text, then returns it as the message content. */
function recordingPrepare(prepared: string[], text: string): DeliveryRequest['prepare'] {
return () => {
prepared.push(text)
return Promise.resolve([{ type: 'text', text }])
}
}
/** Let pending deliveries run as far as they can. */
async function settle(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
/** The rejection of a delivery that must fail. */
async function refusalOf(delivery: Promise<unknown>): Promise<unknown> {
return await delivery.then(() => { throw new Error('the message was delivered') }, (error: unknown) => error)
}
async function harness(): Promise<{
ctx: Context
controller: AgentController
disposeController: () => Promise<void>
agent: Agent
delivered: Delivery[]
recordingAgent: (rawId: string, deliveries: Delivery[], refusal?: Error) => Promise<{ agent: Agent; dispose: () => Promise<void> }>
dispose: () => Promise<void>
}> {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const controllerFiber = await ctx.plugin(AgentController)
const recordingAgent = async (rawId: string, deliveries: Delivery[], refusal?: Error) => {
const id = SessionId(rawId)
const receive = (mode: DeliveryMode, received: UserMessage): void => {
if (refusal !== undefined) throw refusal
deliveries.push({ mode, text: textOf(received), id: received.id })
}
const agent: Agent = {
id,
options: {},
session: Session.create(id),
inbox: untouchedInbox(),
status: 'idle',
ctx,
send: () => {},
followup: (received) => { receive('followup', received) },
steer: (received) => { receive('steer', received) },
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
const unregister = await ctx.agents.register(agent)
return { agent, dispose: async () => { await unregister() } }
}
const delivered: Delivery[] = []
const { agent, dispose } = await recordingAgent('delivery-agent', delivered)
return {
ctx,
controller: ctx.agentController,
disposeController: async () => { await controllerFiber.dispose() },
agent,
delivered,
recordingAgent,
dispose,
}
}
describe('agent controller delivery', () => {
it('appends the prepared message with followup or steer only after every listener, in registration order, admitted it', async () => {
const { ctx, agent, delivered } = await harness()
const unwatched = await ctx.agentController.deliver(agent, request('unwatched', { source: { kind: 'schedule' } }))
expect(delivered).toEqual([{ mode: 'followup', text: 'unwatched', id: unwatched }])
const order: string[] = []
ctx.on('agent-controller/admission', async ({ agent: subject, message }, next) => {
expect(subject).toBe(agent)
expect(message.source).toEqual({ kind: 'user' })
order.push(`first ${textOf(message)}`)
const decision = await next()
order.push(`first got ${decision.kind} with ${String(delivered.length)} delivered`)
return decision
})
ctx.on('agent-controller/admission', async (_admission, next) => {
order.push('second')
return await next()
})
const steered = await ctx.agentController.deliver(agent, request('steered', { mode: 'steer' }))
expect(order).toEqual(['first steered', 'second', 'first got admit with 1 delivered'])
expect(delivered).toEqual([
{ mode: 'followup', text: 'unwatched', id: unwatched },
{ mode: 'steer', text: 'steered', id: steered },
])
})
it('holds a message until its listener passes it on, keeping later messages to that Agent behind it, preparation included', async () => {
const { ctx, agent, delivered, recordingAgent } = await harness()
const other: Delivery[] = []
const { agent: otherAgent } = await recordingAgent('other-agent', other)
const release = Promise.withResolvers<undefined>()
const prepared: string[] = []
const seen: string[] = []
ctx.on('agent-controller/admission', async ({ message }, next) => {
seen.push(textOf(message))
if (textOf(message) === 'first') await release.promise
return await next()
})
const prepareText = (text: string): DeliveryRequest['prepare'] => recordingPrepare(prepared, text)
const first = ctx.agentController.deliver(agent, request('first', { prepare: prepareText('first') }))
const second = ctx.agentController.deliver(agent, request('second', { prepare: prepareText('second') }))
await ctx.agentController.deliver(otherAgent, request('elsewhere', { prepare: prepareText('elsewhere') }))
await settle()
expect(prepared).toEqual(['first', 'elsewhere'])
expect(seen).toEqual(['first', 'elsewhere'])
expect(delivered).toEqual([])
expect(other.map(delivery => delivery.text)).toEqual(['elsewhere'])
release.resolve(undefined)
await Promise.all([first, second])
expect(prepared).toEqual(['first', 'elsewhere', 'second'])
expect(delivered.map(delivery => delivery.text)).toEqual(['first', 'second'])
})
it('rejects a message with the decision of a listener, skips later listeners, and delivers the next message', async () => {
const { ctx, agent, delivered } = await harness()
let reject = true
let later = 0
ctx.on('agent-controller/admission', async (_admission, next) => {
if (reject) return { kind: 'reject', reason: 'fixture/missing', message: 'The fixture is missing.', presented: true }
return await next()
})
ctx.on('agent-controller/admission', async (_admission, next) => {
later += 1
return await next()
})
const refusal = await refusalOf(ctx.agentController.deliver(agent, request('refused')))
expect(refusal).toBeInstanceOf(AgentAdmissionRejected)
expect(refusal).toMatchObject({
name: 'AgentAdmissionRejected',
code: 'AGENT_ADMISSION_REJECTED',
reason: 'fixture/missing',
presented: true,
message: 'The fixture is missing.',
})
expect(delivered).toEqual([])
expect(later).toBe(0)
reject = false
await ctx.agentController.deliver(agent, request('admitted'))
expect(delivered.map(delivery => delivery.text)).toEqual(['admitted'])
expect(later).toBe(1)
})
it.each([
['before it passes the message on', false],
['after the rest of the chain admitted the message', true],
])('rejects a message whose listener throws %s as a failed check', async (_label, afterNext) => {
const { ctx, agent, delivered } = await harness()
const broken = new Error('listener broke')
ctx.on('agent-controller/admission', async (_admission, next) => {
if (afterNext) await next()
throw broken
})
const refusal = await refusalOf(ctx.agentController.deliver(agent, request('broken')))
expect(refusal).toMatchObject({ code: 'AGENT_ADMISSION_REJECTED', reason: ADMISSION_FAILED, presented: false, cause: broken })
expect(refusal).toHaveProperty('message', expect.stringContaining('listener broke'))
expect(delivered).toEqual([])
})
it('passes the failure of preparation to the sender unchanged without asking a listener', async () => {
const { ctx, agent, delivered } = await harness()
const unreadable = new Error('attachment unreadable')
let asked = 0
ctx.on('agent-controller/admission', async (_admission, next) => {
asked += 1
return await next()
})
await expect(ctx.agentController.deliver(agent, request('unreadable', { prepare: () => Promise.reject(unreadable) })))
.rejects.toBe(unreadable)
expect(asked).toBe(0)
expect(delivered).toEqual([])
})
it('runs beforeInsert with the admitted message right before it enters the inbox, and keeps the message out when it throws', async () => {
const { ctx, agent, delivered } = await harness()
const observed: { readonly text: string; readonly deliveredBefore: number }[] = []
const id = await ctx.agentController.deliver(agent, request('recorded', {
beforeInsert: (message) => { observed.push({ text: textOf(message), deliveredBefore: delivered.length }) },
}))
expect(observed).toEqual([{ text: 'recorded', deliveredBefore: 0 }])
expect(delivered).toEqual([{ mode: 'followup', text: 'recorded', id }])
const unbound = new Error('upload binding failed')
await expect(ctx.agentController.deliver(agent, request('unbound', { beforeInsert: () => { throw unbound } })))
.rejects.toBe(unbound)
expect(delivered.map(delivery => delivery.text)).toEqual(['recorded'])
})
it('passes the refusal of the Agent itself to queue a message to the sender unchanged', async () => {
const { ctx, recordingAgent } = await harness()
const full = new Error('inbox refused the message')
const { agent: refusing } = await recordingAgent('refusing-agent', [], full)
await expect(ctx.agentController.deliver(refusing, request('overflow', { mode: 'steer' }))).rejects.toBe(full)
})
it('rejects a message whose Agent left the registry while a listener held it', async () => {
const { ctx, agent, delivered, dispose } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
const pending = refusalOf(ctx.agentController.deliver(agent, request('orphaned')))
await settle()
await dispose()
release.resolve(undefined)
expect(await pending).toMatchObject({ code: 'AGENT_ADMISSION_REJECTED', reason: ADMISSION_AGENT_GONE, presented: false })
expect(delivered).toEqual([])
})
it('rejects a message whose Agent left the registry before its turn without preparing it', async () => {
const { ctx, agent, delivered, dispose } = await harness()
const release = Promise.withResolvers<undefined>()
let prepared = false
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
const blocking = refusalOf(ctx.agentController.deliver(agent, request('blocking')))
const waiting = refusalOf(ctx.agentController.deliver(agent, request('waiting', {
prepare: () => {
prepared = true
return Promise.resolve([{ type: 'text', text: 'waiting' }])
},
})))
await settle()
await dispose()
release.resolve(undefined)
expect(await blocking).toMatchObject({ reason: ADMISSION_AGENT_GONE })
expect(await waiting).toMatchObject({ reason: ADMISSION_AGENT_GONE })
expect(prepared).toBe(false)
expect(delivered).toEqual([])
})
it('stops a message its sender cancels while a listener holds it, while it waits for its turn, during preparation, or before it is appended', async () => {
const { ctx, agent, delivered } = await harness()
const release = Promise.withResolvers<undefined>()
const unheeded = Promise.withResolvers<undefined>()
const seen: string[] = []
ctx.on('agent-controller/admission', async ({ message, signal }, next) => {
seen.push(textOf(message))
if (textOf(message) === 'blocking') await release.promise
// A listener that ignores the sender's signal still cannot append a cancelled message.
if (textOf(message) === 'unheeded') await unheeded.promise
if (textOf(message) === 'held') {
await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
}
return await next()
})
const heldSender = new AbortController()
const held = ctx.agentController.deliver(agent, request('held', { signal: heldSender.signal }))
await settle()
const heldReason = new Error('held sender left')
heldSender.abort(heldReason)
await expect(held).rejects.toBe(heldReason)
const blocking = ctx.agentController.deliver(agent, request('blocking'))
const waitingSender = new AbortController()
const waiting = ctx.agentController.deliver(agent, request('waiting', { signal: waitingSender.signal }))
const waitingReason = new Error('waiting sender left')
waitingSender.abort(waitingReason)
release.resolve(undefined)
await blocking
await expect(waiting).rejects.toBe(waitingReason)
const preparingSender = new AbortController()
const preparingReason = new Error('preparing sender left')
const preparing = ctx.agentController.deliver(agent, request('preparing', {
signal: preparingSender.signal,
prepare: () => {
preparingSender.abort(preparingReason)
return Promise.resolve([{ type: 'text', text: 'preparing' }])
},
}))
await expect(preparing).rejects.toBe(preparingReason)
const unheededSender = new AbortController()
const ignored = ctx.agentController.deliver(agent, request('unheeded', { signal: unheededSender.signal }))
await settle()
const unheededReason = new Error('unheeded sender left')
unheededSender.abort(unheededReason)
unheeded.resolve(undefined)
await expect(ignored).rejects.toBe(unheededReason)
expect(seen).toEqual(['held', 'blocking', 'unheeded'])
expect(delivered.map(delivery => delivery.text)).toEqual(['blocking'])
})
it('ends the wait of a message whose sender cancels behind a held one, keeping later messages in order', async () => {
const { ctx, agent, delivered } = await harness()
const release = Promise.withResolvers<undefined>()
const prepared: string[] = []
ctx.on('agent-controller/admission', async ({ message, signal }, next) => {
if (textOf(message) === 'held') {
// Holds until released, and lets a stopping controller end the hold.
await new Promise<void>((resolve, reject) => {
void release.promise.then(() => { resolve() })
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
}
return await next()
})
const prepareText = (text: string): DeliveryRequest['prepare'] => recordingPrepare(prepared, text)
const held = ctx.agentController.deliver(agent, request('held', { prepare: prepareText('held') }))
const queuedSender = new AbortController()
const queued = refusalOf(ctx.agentController.deliver(agent, request('queued', {
signal: queuedSender.signal, prepare: prepareText('queued'),
})))
const goneSender = new AbortController()
const goneReason = new Error('sender left before delivering')
goneSender.abort(goneReason)
const gone = refusalOf(ctx.agentController.deliver(agent, request('gone', {
signal: goneSender.signal, prepare: prepareText('gone'),
})))
const later = ctx.agentController.deliver(agent, request('later', { prepare: prepareText('later') }))
await settle()
const queuedReason = new Error('queued sender left')
queuedSender.abort(queuedReason)
// Both senders hear their own reason while the held message still blocks the Agent.
expect(await Promise.race([queued, settle().then(() => 'still waiting')])).toBe(queuedReason)
expect(await gone).toBe(goneReason)
expect(prepared).toEqual(['held'])
release.resolve(undefined)
await Promise.all([held, later])
expect(prepared).toEqual(['held', 'later'])
expect(delivered.map(delivery => delivery.text)).toEqual(['held', 'later'])
})
it('finishes disposal only after the deliveries it stopped have settled', async () => {
const { ctx, controller, disposeController, agent, delivered } = await harness()
const cleanup = Promise.withResolvers<undefined>()
const steps: string[] = []
ctx.on('agent-controller/admission', async ({ signal }, next) => {
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
steps.push('stop observed')
await cleanup.promise
steps.push('cleaned up')
return await next()
})
const held = refusalOf(controller.deliver(agent, request('held')))
await settle()
let disposed = false
const disposal = disposeController().then(() => { disposed = true })
await settle()
expect(steps).toEqual(['stop observed'])
expect(disposed).toBe(false)
cleanup.resolve(undefined)
await disposal
expect(steps).toEqual(['stop observed', 'cleaned up'])
expect(await held).toHaveProperty('message', expect.stringContaining('agent controller stopped'))
expect(delivered).toEqual([])
})
it('stops every message that has not entered the inbox when the controller is disposed', async () => {
const { ctx, controller, disposeController, agent, delivered } = await harness()
const seen: string[] = []
ctx.on('agent-controller/admission', async ({ message, signal }, next) => {
seen.push(textOf(message))
await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
return await next()
})
const held = refusalOf(controller.deliver(agent, request('held')))
const waiting = refusalOf(controller.deliver(agent, request('waiting')))
await settle()
await disposeController()
for (const refusal of [await held, await waiting, await refusalOf(controller.deliver(agent, request('late')))]) {
expect(refusal).toHaveProperty('message', expect.stringContaining('agent controller stopped'))
}
expect(seen).toEqual(['held'])
expect(delivered).toEqual([])
})
})
@@ -1,24 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/scope"
},
{
"path": "../../llm/llm"
}
]
}
@@ -1,15 +0,0 @@
import { defineConfig } from 'tsdown'
/** Build the package root as a self-contained bundle. */
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])
@@ -81,20 +81,6 @@ export interface TypeApiEntry {
/** Every harness `ctx.<key>` service, sorted by key. */
export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'agentController',
summary: 'Delivers the messages that senders, such as the Web Session Remote, bring on someone\'s behalf.',
description: 'Delivers the messages that senders, such as the Web Session Remote, bring on someone\'s behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.',
methods: [
{
signature: 'async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>',
description: 'Deliver one message from outside the Agent: prepare its content, ask the `agent-controller/admission` stage for a decision, then append an admitted message with `followup` or `steer`.',
parameters: [{ name: 'agent', description: 'the receiving Agent.' }, { name: 'request', description: 'delivery mode, source, cancellation, and the sender\'s content preparation.' }],
returns: 'the id of the message once it is in the inbox.',
throws: ['{@link AgentAdmissionRejected} when a listener rejects the message or throws, or when the Agent left the registry first; the error of `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender\'s cancellation reason as soon as it cancels, also while the message waits behind another one; the stop reason once the controller stops.'],
},
],
},
{
key: 'agentDefaultModel',
summary: 'Owns the default model selection independently of any Host or transport.',
@@ -2301,10 +2287,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote(\'prompt\') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>',
description: 'Admit one prompt after explicitly resuming its Session. The prompt is delivered through `ctx.agentController`, whose `agent-controller/admission` stage runs before it enters the inbox, so the call can wait for a listener that holds it.',
parameters: [{ name: 'request', description: 'Session identity, prompt content, source metadata, and delivery mode.' }, { name: 'signal', description: 'caller cancellation; it also stops a prompt waiting for or held at the admission stage.' }],
description: 'Admit one prompt after explicitly resuming its Session.',
parameters: [{ name: 'request', description: 'Session identity, prompt content, source metadata, and delivery mode.' }, { name: 'signal', description: 'caller cancellation before prompt admission begins.' }],
returns: 'acknowledgement that the Agent accepted the prompt.',
throws: ['`session/prompt-rejected` when an admission listener refuses the prompt or fails.'],
},
{
signature: '@Remote(\'attachment\') attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue>',
@@ -4152,14 +4137,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
/** Every harness event, sorted by name. */
export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'agent-controller/admission',
mode: 'waterfall',
signature: '\'agent-controller/admission\'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>',
summary: 'Decide whether one message from outside an Agent enters its inbox.',
description: 'Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent\'s own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener\'s check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.',
parameters: [{ name: 'payload', description: '.signal - aborted when the sender cancels or the controller stops; a holding listener must settle once it aborts, because controller disposal waits for it. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.' }],
},
{
name: 'agent-loop/config-start-failed',
mode: 'emit',
@@ -4880,10 +4857,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AdapterRegistrationHandle',
declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}',
},
{
name: 'AdmissionDecision',
declaration: 'export type AdmissionDecision = {\n readonly kind: \'admit\';\n} | {\n readonly kind: \'reject\';\n readonly reason: string;\n readonly message: string;\n readonly presented: boolean;\n};',
},
{
name: 'AdmittedPromptContentPart',
declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n} | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};',
@@ -5544,14 +5517,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DeepSeekLlmApiJson',
declaration: 'export type DeepSeekLlmApiJson = null | boolean | number | string | DeepSeekLlmApiJson[] | {\n [key: string]: DeepSeekLlmApiJson;\n};',
},
{
name: 'DeliveryMode',
declaration: 'export type DeliveryMode = \'followup\' | \'steer\';',
},
{
name: 'DeliveryRequest',
declaration: 'export interface DeliveryRequest {\n readonly mode: DeliveryMode;\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n readonly prepare: (signal: AbortSignal) => Promise<UserMessage[\'content\']>;\n readonly beforeInsert?: (message: UserMessage) => void;\n}',
},
{
name: 'DeliveryRetentionBounds',
declaration: 'export interface DeliveryRetentionBounds {\n readonly days: number;\n readonly records: number;\n}',
@@ -6436,10 +6401,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'MessageRoleMap',
declaration: 'export interface MessageRoleMap {\n system: SystemMessage;\n developer: DeveloperMessage;\n user: UserMessage;\n assistant: AssistantMessage;\n tool: ToolResultMessage;\n}',
},
{
name: 'MessageSource',
declaration: 'export type MessageSource = MessageSourceMap[keyof MessageSourceMap];',
},
{
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n \'system-prompt\': SystemPromptMessageSource;\n}',
@@ -153,7 +153,6 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo
| Package | Config | Description |
|---|---|---|
| `@deepseek-ai/dsh-agent` | no | Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness |
| `@deepseek-ai/dsh-agent-controller` | no | Delivers messages from outside an Agent into its inbox through an admission stage |
| `@deepseek-ai/dsh-agent-default-model` | yes | Default model selection shared by Agent entry points |
| `@deepseek-ai/dsh-agent-loop` | yes | The concrete agent loop plugin for the DeepSeek Harness |
| `@deepseek-ai/dsh-agent-tool-presentation` | yes | Agent-plane presentation selector: composes one agent's tools as PTC mode or native tool calls |
-30
View File
@@ -1525,9 +1525,6 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:*
version: link:../../core/agent
'@deepseek-ai/dsh-agent-controller':
specifier: workspace:*
version: link:../../core/agent-controller
'@deepseek-ai/dsh-agent-default-model':
specifier: workspace:*
version: link:../../core/agent-default-model
@@ -2274,9 +2271,6 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:*
version: link:../../core/agent
'@deepseek-ai/dsh-agent-controller':
specifier: workspace:*
version: link:../../core/agent-controller
'@deepseek-ai/dsh-agent-default-model':
specifier: workspace:*
version: link:../../core/agent-default-model
@@ -7307,27 +7301,6 @@ importers:
specifier: workspace:*
version: link:../../workspace/workspace
packages/core/agent-controller:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-agent':
specifier: workspace:*
version: link:../agent
'@deepseek-ai/dsh-llm':
specifier: workspace:*
version: link:../../llm/llm
'@deepseek-ai/dsh-scope':
specifier: workspace:*
version: link:../scope
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../session
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
packages/core/agent-default-model:
dependencies:
'@deepseek-ai/dsh-config-editor':
@@ -15947,9 +15920,6 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:*
version: link:../../packages/core/agent
'@deepseek-ai/dsh-agent-controller':
specifier: workspace:*
version: link:../../packages/core/agent-controller
'@deepseek-ai/dsh-agent-default-model':
specifier: workspace:*
version: link:../../packages/core/agent-default-model
-1
View File
@@ -14,7 +14,6 @@
"@deepseek-ai/dsh": "workspace:*",
"@deepseek-ai/dsh-acp": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-instructions": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"AGENTS.md": 2080,
"docs/AGENTS.md": 1430,
"docs/architecture.md": 2430,
"docs/architecture.md": 2410,
"docs/cordis-primer.md": 600,
"docs/testing.md": 1350,
"packages/AGENTS.md": 810,
-5
View File
@@ -67,7 +67,6 @@ export const SERVICE_PAGE: Record<string, string> = {
hmr: 'boot.md',
mcpResources: 'mcp.md',
agentLoop: 'core.md',
agentController: 'core.md',
agentDefaultModel: 'core.md',
agentPresets: 'core.md',
agents: 'core.md',
@@ -226,7 +225,6 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
'plugin-manager': 'boot.md',
'plugin-market': 'boot.md',
'agent': 'core.md',
'agent-controller': 'core.md',
'agent-loop': 'core.md',
'agent-preset': 'core.md',
'api-session': 'session.md',
@@ -353,9 +351,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
AllowedModelRoute: 'subagent.md',
SubagentModelSelectionSettings: 'subagent.md',
AgentOptions: 'core.md',
AdmissionDecision: 'core.md',
DeliveryMode: 'core.md',
DeliveryRequest: 'core.md',
TurnBoundaryProjection: 'core.md',
AgentStatus: 'core.md',
AssistantStreamFrame: 'core.md',
-8
View File
@@ -641,14 +641,6 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'acp', 'subagent'],
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
key: 'agentController',
pkg: 'agent-controller',
title: 'Outside-message delivery',
mode: 'core',
consumers: ['api-session-controller'],
note: 'Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone\'s behalf, one at a time per Agent.',
},
{
key: 'agentDefaultModel',
pkg: 'agent-default-model',
-15
View File
@@ -2526,21 +2526,6 @@
"doc": "docs/subsystems/session-projection.md",
"symbol": "ProjectionKeySelection",
"source": "packages/session/session-projection/src/index.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "DeliveryMode",
"source": "packages/core/agent-controller/src/types.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "DeliveryRequest",
"source": "packages/core/agent-controller/src/types.ts"
},
{
"doc": "docs/subsystems/core.md",
"symbol": "AdmissionDecision",
"source": "packages/core/agent-controller/src/types.ts"
}
]
}
@@ -97,7 +97,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/util/plugin-market-index': { kind: 'indirect', reason: 'Pure index validation; the plugin_market tool owns the entries it returns to the model.' },
'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
'packages/core/agent-controller': { kind: 'indirect', reason: 'The controller appends sender-prepared messages to the inbox; the Agent loop owns how they enter a model request.' },
'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' },
'packages/preset/agent-preset': { kind: 'indirect', reason: 'The declaration selects plugins that own model-visible contributions.' },
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
-1
View File
@@ -374,7 +374,6 @@
"@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"],
"@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"],
"@deepseek-ai/dsh-agent": ["./packages/core/agent/src"],
"@deepseek-ai/dsh-agent-controller": ["./packages/core/agent-controller/src"],
"@deepseek-ai/dsh-agent-default-model": ["./packages/core/agent-default-model/src"],
"@deepseek-ai/dsh-agent-instructions": ["./packages/context/agent-instructions/src"],
"@deepseek-ai/dsh-agent-loop": ["./packages/core/agent-loop/src"],
-1
View File
@@ -381,7 +381,6 @@
{ "path": "./packages/session/session-telemetry-otel" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/core/agent-controller" },
{ "path": "./packages/core/agent-default-model" },
{ "path": "./packages/interaction/commands" },
{ "path": "./packages/goal/goal" },