Skip to content
16 changes: 16 additions & 0 deletions superagent/deployment-config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const AGENT_FIELDS = Object.freeze([
'ovAutoRecall',
'explicitSilence',
'stagedReplies',
'scheduler',
'role',
'naturalWriting',
'model',
Expand All @@ -119,6 +120,12 @@ const ROLE_PRESETS = Object.freeze(['colleague', 'assistant', 'qa']);
// agents[].larkMessageAccess: `owner` reads with the owner's visibility,
// `bot` only in the chats the bot has joined.
const LARK_MESSAGE_ACCESS = Object.freeze(['owner', 'bot']);
// How inputs reach the model (agents[].scheduler). legacy: every input goes
// into the running turn as soon as it arrives. inbox: while the model works,
// inputs wait and it gets a notice; it reads them, oldest first, at a
// checkpoint (message_check), or they start the next turn. workers: the inbox,
// and the main session hands longer work to worker sessions (tasks).
const SCHEDULERS = Object.freeze(['legacy', 'inbox', 'workers']);
// Each mailbox is its own email Connector instance; the id is how the model
// and the logs tell them apart.
const EMAIL_INSTANCE_ID = /^email-[a-z0-9][a-z0-9-]{0,39}$/;
Expand Down Expand Up @@ -326,6 +333,13 @@ function parseAgent(value, index, ignored) {
if (value.stagedReplies !== undefined && typeof value.stagedReplies !== 'boolean') {
throw new Error(`${label}.stagedReplies must be a boolean`);
}
if (value.scheduler !== undefined && !SCHEDULERS.includes(value.scheduler)) {
throw new Error(`${label}.scheduler must be ${SCHEDULERS.join(' or ')}`);
}
// The inbox is read with a Core tool, which Ark has not got.
if (value.scheduler !== undefined && value.scheduler !== 'legacy' && model === 'ark') {
throw new Error(`${label}.scheduler ${value.scheduler} needs a runtime with Core tools; ark has none`);
}
if (value.meetingNotes !== undefined && typeof value.meetingNotes !== 'boolean') {
throw new Error(`${label}.meetingNotes must be a boolean`);
}
Expand Down Expand Up @@ -364,6 +378,7 @@ function parseAgent(value, index, ignored) {
// explicit false opts out.
explicitSilence: value.explicitSilence ?? model !== 'ark',
stagedReplies: value.stagedReplies ?? false,
scheduler: value.scheduler ?? 'legacy',
role: manifestRole(value.role, `${label}.role`),
naturalWriting: value.naturalWriting ?? true,
model,
Expand Down Expand Up @@ -594,6 +609,7 @@ function applyDeploymentAgentEnv(env, deployment, requestedId) {
env.OV_SUPERAGENT_EXPLICIT_SILENCE = agent.explicitSilence ? 'true' : 'false';
env.OV_SUPERAGENT_NATURAL_WRITING = agent.naturalWriting ? 'true' : 'false';
env.OV_SUPERAGENT_STAGED_REPLIES = agent.stagedReplies ? 'true' : 'false';
env.OV_SUPERAGENT_SCHEDULER = agent.scheduler;
env.OV_SUPERAGENT_MEETING_NOTES = agent.meetingNotes ? 'true' : 'false';
env.OV_SUPERAGENT_DSH_HOME_MODE = agent.dshHomeMode;
env.OV_SUPERAGENT_DSH_SKILL_ROOTS = JSON.stringify(agent.dshSkillRoots);
Expand Down
10 changes: 10 additions & 0 deletions superagent/docs/dsh-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,16 @@ OpenViking SuperAgent session id 就是 DSH session id。`session/working-direct

assistant 文本、reasoning、工具调用、usage 和结束原因写入 OpenViking SuperAgent 活动记录;子 agent 的事件不作为根会话活动。回复始终通过 `personal_agent`,活动页中的 assistant 文本不作为聊天回复。活动行的事件键含每次进程运行的 `runId`,同一 session 重启不会把新行当成重复丢掉。

## Router + Worker

实例使用 `agents[].scheduler: "workers"` 时,DSH 可以作为 Router 和 Worker runtime。主 Router 与所有 Worker 共用同一个 DSH profile、DSH_HOME、模型设置和凭据,不为任务复制 home,也不在每个 turn 重启 DSH。

每个任务对应一个原生 Session,由 bridge 通过 `agents.create/resume` 创建或恢复,preset 为 `ov-superagent-worker`。`worker-executor.mjs` 把 #7227 的 `start/done/stop` 映射为 `superagent/worker/ensure → submit → wait/cancel`:同一任务串行,不同任务并行,取消一个 Worker 不影响 Router 或其他 Worker。提交以 task/turn 为 request id,同 turn 重试不会重复插入消息。

Worker preset 只含编码、文件、搜索、jobs 和 skills 等工作工具,不含 DSH 的提问、定时器、插件管理和 subagent 入口。bridge 在每个 Session 上注册 Core 的 `task_check`、`task_report` 和只读工具,并在 turn 结束时撤销 authority;任务状态、3+1 slot、输入认领、报告 outbox 和 Router 单出口仍由 #7227 的 TaskManager 统一管理。

`npm run check:dsh` 同时用真实 DSH 检查 Worker preset 创建、重复提交去重、独立 cancel 和 wait 结算,不需要真实模型密钥。

## 怎么自检、有哪些已知限制

```sh
Expand Down
230 changes: 230 additions & 0 deletions superagent/docs/scheduler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# Scheduler

How accepted inputs reach the model. Set per instance with `agents[].scheduler`
in `config.json` (`OV_SUPERAGENT_SCHEDULER` in the daemon env), read once at start.

| Value | Behaviour |
|---|---|
| `legacy` (default) | Every input goes into the running turn as soon as it arrives (Codex/TraeX `turn/steer`, Pi `steer`, ...); an idle runtime starts a turn with it. Group lines that arrive within 400 ms start one turn together. |
| `inbox` | While the model works, inputs wait and the model is told they are there; it reads them at a checkpoint. |
| `workers` | The inbox, and the main session becomes a router: it hands longer work to worker sessions on the same runtime and relays their results. |

With `legacy`, the prompt, the tool list, the policyHash and the database schema
are exactly what they were before the setting existed.

## Inbox

Two problems with putting every input into the running turn at once:

- A long task is interrupted by every new line, from every chat.
- Under load the model tends to answer the newest line and lose the older ones.

The inbox (`packages/daemon/src/inbox.mjs`) changes when and how inputs reach the
model, not what they look like: an input line is the same line either way, with
the same `event=`, `msg=` and authenticated tags.

### Flow

```
Connector event → Harness (validate, dedupe, ledger) → inbox_pending
│
runtime idle ──────────────────────────────────┤── wake: start a turn with the oldest
│ waiting inputs (up to 20; the rest
│ are noticed in that turn)
runtime working ───────────────────────────────┤── notice: one [daemon inbox] line,
│ ~3 s after the first input of a burst
message_check / held delivery ─────────────────┘── deliver: oldest first, through the
runtime's own submit path
```

- **Wake.** An idle runtime starts a turn with the oldest waiting inputs. A
direct line starts it at once, a group line after the legacy 400 ms burst
window, so a burst still starts one turn.
- **Notice.** While a turn runs, the first waiting input of a burst arms a
3-second timer that later inputs do not extend. The notice is one line per
chat with new inputs: where (`chat=`/`thread=`/`source=`/`type=`), how many
wait there, the latest senders, and `owner=1` / `mention=me` when the owner
or a mention of the agent is among them. Its header counts every waiting
input. It never carries message text, and it is not an input: nothing is
decided on it. A chat already noticed is named again only when new inputs
arrive there.
- **`message_check`.** The model reads waiting inputs at a checkpoint in its
work, oldest first, optionally from one chat or thread. They are delivered
through `AgentRuntime.submit`, so the adapter books them like any steered
input: the cognition journal, the turn's inbound seqs (and so the decision
check at turn end), and the `agent.steered` activity.
- **Turn end.** Inputs still waiting when a turn ends start the next one.

### Interplay with the Core

- **Freshness fence.** Unchanged: a delivery is still refused when a newer
accepted input exists in its scope. Under the inbox that input may still be
waiting, so the model could not reconsider anything. The refusal then
delivers that scope's waiting inputs at once and says so; a `stay_silent` on
a superseded input does the same.
- **Decisions.** An input counts as consumed when it is delivered, by a wake,
`message_check` or a held delivery, exactly as a steered input does. Inputs
never delivered are never nudged.
- **Durability.** Waiting inputs are rows in `inbox_pending` (created on first
use, so a legacy instance never has the table). A restart delivers them
again; rows older than the event age limit (`OV_SUPERAGENT_MAX_EVENT_AGE_MS`) are
dropped like a stale event. Legacy keeps its known gap: an input inside the
400 ms group window when the process dies is not delivered.
- **Runtimes.** The inbox needs the Core tools (`message_check`). Ark has none,
so a manifest with `scheduler: "inbox"` on Ark is rejected; switching to Ark
at run time delivers everything waiting at once and routes new inputs the
legacy way. A runtime that cannot take input mid-turn (OpenCode,
`activeInput: queue-next-turn`) gets no notices; its waiting inputs start its
next turn.
- **Slash commands, approvals, meeting notes.** Slash commands and approval
replies are handled by the Core before the inbox. Daemon notes about a chat
(`[mail reply sent]`) and undecided-line reminders wait in the inbox like
inputs. The meeting inbox keeps its own notice.

### Observing it

Activities `inbox.notified` (how many wait, how many are new) and
`inbox.delivered` (`reason`: `wake`, `check`, `held_delivery` or `drain`, the
inbound seqs, and `waitedMs`, the longest wait among them).

## Workers

With `workers`, the main session is a **router**. It still reads every input,
through the inbox above, and answers what is quick. A request that needs real
work (running or changing code, investigating, research, writing a document)
becomes a **task** (`packages/daemon/src/tasks.mjs`), and a **worker** does it:
a separate session on the router's own runtime. The worker never talks to
people; it reports, and the router relays the report where the request was
made. The router stays the only outlet, so every check on the delivery path
(authority, freshness fence, thread placement, bot fuse, approvals, outbox
idempotency) applies to what a worker produced, without a second copy.

```
input ─▶ inbox ─▶ router ──task_create / task_route──▶ task (SQLite) ──▶ worker turn
▲ │
└──────── [task report] (through the inbox) ◀── task_report
router ──reply_message / send_message──▶ ActionService ──▶ Connector
```

### Tasks

A task is a row in `tasks` (created on first use, like `inbox_pending`): its
origin (the input the result answers: `event=`, `msg=`, chat, thread, requester),
title, the router's note (≤ 30 words of routing facts, never a plan), status,
the worker's session, and the latest summary, draft, question and artifacts.
`task_inputs` holds every line routed to it, in order. `task_input_claims`
associates inputs with the turn reading them (including `task_check`); only a
successful turn acknowledges them. Failed or interrupted turns release their
claims so the inputs can be replayed. `task_reports` is the append-only list of
reports.

Status: `queued` (waiting for a slot), `running`, `waiting` (a question for a
person), `blocked`, and the ended `done`, `dropped`, `failed`, `stopped`. A
requester's follow-up resumes an ended task, except a stopped one.

### Router tools

| Tool | Does |
|---|---|
| `task_create` | A task from the named input lines (`trigger_ids`, the first one is the origin), a title and an optional note. |
| `task_route` | More lines for a task: `owner` (adds to, changes or answers the request; resumes an ended task) or `context` (background). |
| `task_stop` | Stops a task and its worker; only when the requester or the owner asks. |
| `task_list` | The working set (every live task and the 32 most recently ended), one task in detail, or a search over titles. |

Routing an input to a task records it as decided (`decisions`, `silent`), so the
turn-end check does not ask again; the relayed result records the delivery over
it. A `[daemon tasks]` line on an input names the tasks it may belong to and why:
same thread, a reply to the task's messages, a person the task is waiting for,
the same requester, similar words (character-pair overlap, ranking only). Only
the owner's lines are matched against tasks in other chats.

### Workers

Pi, Codex and Claude Code run one worker turn as a non-interactive CLI process,
resuming the task's own session (`worker-executor.mjs`). DSH instead keeps one
profile process for the router and workers: each task gets a native session with
the `ov-superagent-worker` preset, and the existing JSON-RPC bridge drives it.
The DSH worker uses the same TaskManager contract and turn-scoped Core authority; cancelling one worker does not cancel the router or other workers.

| Runtime | Turn |
| --- | --- |

| Runtime | Turn |
|---|---|
| Pi | `pi --mode json -p --session-id <task> ...` |
| Codex | `codex exec --json ...`, then `codex exec resume <thread>` |
| Claude Code | `claude --print --output-format stream-json --session-id/--resume <task>` |
| DSH | `worker/ensure → submit → wait` in the router's DSH profile, `worker-<task>` with the worker preset |

No process runs between CLI turns, so an idle CLI task holds no process. DSH sessions stay in the shared profile and resume on the next task turn. Durable,
OpenCode, TraeX and Ark have no worker mode, and `task_create` tells the router
to handle the request itself there. A task keeps the runtime it started on.
Three turns run at once, plus one slot only a follow-up to an existing task may
take. Each task works in `<workspace>/tasks/<id>/`.

A worker's policy is `agent/worker.md` plus the instance Role. Its first input
is the brief: the original input lines, who asked and where, the router's note
and its directory. Its Core tools are `task_check` (lines routed to it since it
last looked), `task_report` and the reads (chat and thread history, search,
media, mail, OpenViking reads); never a delivery, a decision, a timer or a
memory write. Its authority token is valid for its turn only.

A turn ends with the latest final `task_report` (`done` with a ready-to-send
draft, `needs_user` with one question, `blocked`, `dropped`, `failed`), or,
without one, the worker's last words as an `unstructured=1` result. Before a
report is relayed, inputs the requester added during the turn must be in it:
if any were not read, the worker runs again (up to three times). A turn that
failed for a passing reason (429, 5xx, a reset connection) runs again, twice at
most.

A successful turn records a session checkpoint. A failed or interrupted turn
starts a new session with the persisted request history, routing note, previous
summary/draft and artifacts, and an instruction to check existing work before
repeating outside actions. A preallocated session ID or turn counter alone is
never evidence that Claude can resume a session. Recovery can repeat work;
outside actions are not guaranteed to run exactly once.

### What the router sees

```
[task report] task=t3 state=done title="排查飞书名字泄露" requester=陈锦
origin: chat=oc_... thread=omt_... source=lark msg=om_... event=...
summary:
| ...
draft:
| ...
```

Worker text is quoted (`| `), so it cannot pass for an input line. The router
receives the full stored draft (up to the 20,000-character reporting limit)
and relays it with `reply_message` to the origin `msg=`.

Ending a task and selecting its final report for delivery are committed together
in SQLite (`task_report_deliveries`). The inbox row, report's delivery timestamp
and report-to-inbox receipt then commit in one transaction, before waking the
router. The report ID is the idempotency key; consuming the inbox row does not
erase its receipt. Failed writes retry automatically and pending deliveries are
recovered at startup, without rerunning the worker. Task reports in the inbox
do not expire under the incoming-event age limit. This guarantees the handoff
to the inbox, not that the model ultimately sends the report to the requester.

### Interplay with the Core

- **OnIt.** The router acknowledges a task with a temporary OnIt. A turn's end
takes OnIt reactions down except in a scope where a task is still `queued` or
`running`; the relayed result takes those down.
- **Router tools.** On Pi the router's own bash commands are capped at 30
seconds and may not `sleep`, and each run gets six calls of its own work
tools (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`); past them the
call is refused with a pointer to `task_create` (a `tool_call` handler in
`pi-mcp-extension.mjs`). A per-command cap alone did not stop a router from
investigating with many short commands. Core reads are not counted. On Codex
and Claude Code this is policy text only.
- **Restart.** Tasks, inputs and reports are in SQLite. A turn cut by a
shutdown is not reported; at start its task runs again from the persisted
request history, told to check what it already did before repeating an outside
action. Selected final reports still awaiting the inbox are delivered again
through their idempotent receipt.

Activities: `task.created`, `task.turn_started`, `task.turn_completed` (with
usage), `task.reported`, `task.ended`.
Loading