feat(jobs): deliver job output in completion notices and drop repeated notice text

- tool-jobs: a completion notice carries the job's unread output when it fits inlineOutputBytes (default 2048); job_output withdraws a notice still pending, and the label is its first line bounded to 120 characters
- tool-bash, tool-pwsh: the promotion result keeps only the still-running marker
- subagent: a settlement notice omits the closing message when the child's last action was a message to its parent
This commit is contained in:
turtle1999 committed 2026-10-12 20:40:48 +08:00
1 parent 3a7e896206
commit 70eab5706e
32 files changed
+556 -82

No files matched your search

+8 -1
View File
@@ -3785,7 +3785,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-jobs`
- `inject`: `tools` · `jobs` · `systemPrompt`
- `source`: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
- `source`: [`packages/jobs/tool-jobs/src/index.ts:43`](../packages/jobs/tool-jobs/src/index.ts)
```ts config-catalog
/** Configures bounded `job_output` waits and completion-notice delivery. */
@@ -3805,6 +3805,13 @@ export interface Config {
* the next user input.
*/
maxConsecutiveWakes?: number
/**
* Largest complete completion notice, in UTF-8 bytes, that carries the
* job's unread output (default 2048); a larger one leaves that output for
* `job_output`. The producer's `outputLimitBytes` also bounds it, and 0
* never inlines output.
*/
inlineOutputBytes?: number
}
/**
+8 -1
View File
@@ -3787,7 +3787,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-jobs`
- `inject`: `tools` · `jobs` · `systemPrompt`
- `source`: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
- `source`: [`packages/jobs/tool-jobs/src/index.ts:43`](../packages/jobs/tool-jobs/src/index.ts)
```ts config-catalog
/** Configures bounded `job_output` waits and completion-notice delivery. */
@@ -3807,6 +3807,13 @@ export interface Config {
* the next user input.
*/
maxConsecutiveWakes?: number
/**
* Largest complete completion notice, in UTF-8 bytes, that carries the
* job's unread output (default 2048); a larger one leaves that output for
* `job_output`. The producer's `outputLimitBytes` also bounds it, and 0
* never inlines output.
*/
inlineOutputBytes?: number
}
/**
+14 -14
View File
@@ -9,8 +9,8 @@
en: 256894de7b318e7e
zh: a6f3cb0060fe2c4d
/deepseek-ai-dsh-tool-jobs/summary:
en: b32de88c29a3c2e0
zh: 63142a605332b4b7
en: 571a1caca9a46ee0
zh: 7d243d648abf9592
/deepseek-ai-dsh-tool-jobs/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -21,11 +21,11 @@
en: e9e9cb2ee5cea462
zh: 833beb2bca84bd6a
/deepseek-ai-dsh-tool-jobs/use-this-package/completion-notices:
en: f69e3f96353234c3
zh: 6caa74174a2d79b1
en: d39d17ea069c8efd
zh: 20d9b35959cba853
/deepseek-ai-dsh-tool-jobs/use-this-package/minimal-configuration:
en: fc08838f1843b8c7
zh: e5d3c11216967aca
en: 7cce8568898e2dd8
zh: 2979e3584d4ee34d
/deepseek-ai-dsh-tool-jobs/use-this-package/what-can-go-wrong:
en: a09c5c8225dec06c
zh: 3e9fcab42613775e
@@ -36,14 +36,14 @@
en: e1d89b73fdcc5b24
zh: 38dd176ae111561f
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/source-map:
en: 6f006ff2d901bc92
zh: 779d18ba7bec4202
en: e6278ea246410c01
zh: d202575c3e53852e
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/output-capping:
en: ca18e4bdfb12a1e2
zh: 6592697c561c6e6d
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/notice-delivery-lanes:
en: 2540b7abbb3c9710
zh: 4fb355bceab2f865
en: eaf309257bd77534
zh: 1f0b7119676c9db7
/deepseek-ai-dsh-tool-jobs/further-exploration:
en: 11250fc631d55d99
zh: 7885ce173bd1cd5f
@@ -81,11 +81,11 @@
en: 8160e01d6bfc5e0c
zh: 570dc95be7f13260
/deepseek-ai-dsh-tool-jobs/model-experience/results-and-notices/what-the-model-sees:
en: 960436b3f61c7f96
zh: 2c956bb4fee134c2
en: 209f0d45364dcedd
zh: 2dd51778a3f99adc
/deepseek-ai-dsh-tool-jobs/model-experience/results-and-notices/token-effect:
en: 16efd9cb948ca4bf
zh: 563cc4c78dc63b4d
en: 40aeb5a93409f4a4
zh: 6f894bb247ffff14
/deepseek-ai-dsh-tool-jobs/model-experience/results-and-notices/kv-cache-effect:
en: 2fbf2175168e8244
zh: f681f89596f500f5
+8 -5
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use `dsh-tool-jobs` to inspect and control background commands and PTY work through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, list results identify each job's kind and status, and cancellation settles only after the work stops. When owned work finishes, the agent receives an in-session notice: busy agents receive it in their next step, while idle agents are woken by a follow-up turn. Configuration controls wait limits, completion delivery, and an optional cap on consecutive wakeups. Stream output is consumed by one reader, and pending notices do not survive owner disposal.
Use `dsh-tool-jobs` to inspect and control background commands and PTY work through `job_output`, `job_list`, and `job_kill`. Reads can wait within a configured timeout, list results identify each job's kind and status, and cancellation settles only after the work stops. When owned work finishes, the agent receives an in-session notice that carries short output inline: busy agents receive it in their next step, while idle agents are woken by a follow-up turn. Configuration controls wait limits, completion delivery, and an optional cap on consecutive wakeups. Stream output is consumed by one reader, and pending notices do not survive owner disposal.
## Table of Contents
@@ -37,7 +37,7 @@ The three tools return `{ text, job }`, `PublicJobSnapshot[]`, and `{ outcome: '
### Completion notices
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` as an in-session message. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. Completions the model already collected get no notice: the registry reports a settlement that released a live `wait` as `awaited` — whether a `job_output` wait or a shell tool waiting on its own foreground command — and the plugin remembers the kills the model requested through `job_kill`; a settlement caused by owner or service teardown is skipped because nobody is left to read it.
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Output:` followed by the output it has not read, or `. No new output.` when there is none. A notice that would exceed `inlineOutputBytes` (2048 bytes by default) or the producer's `outputLimitBytes` ends `. Read its output with job_output.` instead. The label is its first line, bounded to 120 characters. A `job_output` call that collects the job while its notice is still pending withdraws the notice, and a notice that never reaches the model leaves its output for `job_output`. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. Completions the model already collected get no notice: the registry reports a settlement that released a live `wait` as `awaited` — whether a `job_output` wait or a shell tool waiting on its own foreground command — and the plugin remembers the kills the model requested through `job_kill`; a settlement caused by owner or service teardown is skipped because nobody is left to read it.
Waking is unbounded by default: an unattended agent that chains background commands and one-shot subagents is woken for every completion. `maxConsecutiveWakes` caps that: each owner may be woken that many times before further notices degrade to injection, and claiming any user-authored message restores the budget. The cap bounds the self-exciting chain — a woken turn may start the background job whose completion wakes it again — but a notice past it waits silently until the next user input, so a session that relies on wakes to finish its work stalls there. `completionDelivery: quiet` keeps even idle owners on the injection lane, which deterministic transcripts need.
@@ -55,6 +55,7 @@ Loading the plugin with no config is the common path; a `waitTimeoutMs` above `m
| `maxWaitTimeoutMs` | `600,000` | Cap for model-supplied waits; larger values clamp down to it |
| `completionDelivery` | `wakeup` | `wakeup` opens a turn on an idle owner; `quiet` leaves the notice pending |
| `maxConsecutiveWakes` | unset | Turns one owner may open by wake before notices degrade to injection; unset means no cap |
| `inlineOutputBytes` | `2048` | Largest complete notice that carries the job's unread output; `0` never inlines |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-jobs) is the exhaustive source for every accepted field and its JSDoc.
@@ -82,7 +83,7 @@ This section explains the design decisions behind the tools and points at the co
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: tool registrations, the settlement subscription and model-kill set, prompt section, output capping |
| [`src/index.ts`](src/index.ts) | Plugin entry: tool registrations, the settlement subscription, model-kill set, and held settlement reads, prompt section, output capping |
| [`src/render.ts`](src/render.ts) | Model-facing rendering: the public projection, status lines, and the consuming delta (stdout, `[stderr]` section, dropped-output notice) |
### Output capping
@@ -93,6 +94,8 @@ This section explains the design decisions behind the tools and points at the co
The settlement subscription (`{ owners: 'scope' }`) skips settlements the registry reports as `awaited`, jobs the model killed through `job_kill`, unowned jobs, and teardown settlements, then resolves the agent registered for the owner session. A `wakeup` delivery opens a turn on an idle owner; with `maxConsecutiveWakes` set, only while the budget lasts, tracked per exact `Agent` in a `WeakMap`, and claiming a user-authored message (`agent/inbox/claimed`) resets that owner's budget. A busy owner — or any notice past a configured budget, or `quiet` delivery — is injected into the next-step inbox instead. The registry counts only a wait still owed the projection at settlement, so a wait that timed out or was aborted leaves a later settlement to notify as usual; a removal drops the job from the model-kill set, which only ever holds live jobs the model killed.
Each notice takes the job's one consuming read at settlement and holds it with the notice's message id. When an inline notice is logged as a `user/message` (`session/event`), the held read is spent; a notice claimed by a step that is then rejected or cancelled is never logged, so its read stays held. Otherwise `job_output` returns the held read ahead of its own read and removes the notice from the owner's inbox while it is still pending. A job removal drops it.
</details>
-----
@@ -153,11 +156,11 @@ Prefix-stable while tool definitions and visibility are unchanged. Registration
#### What the model sees
Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background jobs)`. Kill returns `requested cancellation of job <id>` or the existing terminal status. An owned completion the model has not already collected uses the notice above.
Reads return output or `(no new output)` followed by `[status: <status>]` and optional detail. An empty list returns `(no background jobs)`. Kill returns `requested cancellation of job <id>` or the existing terminal status. An owned completion the model has not already collected uses the notice above, with the job's unread output when it fits.
#### Token effect
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner only when `maxConsecutiveWakes` is set; a notice reaching a busy owner adds a step to the turn it is already paying for.
Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output, and an inline notice saves the `job_output` step that would otherwise follow it; a producer-supplied `outputLimitBytes` bounds each complete read or notice. Under `wakeup`, a notice reaching an idle owner also buys a model request the user did not ask for, capped per owner only when `maxConsecutiveWakes` is set; a notice reaching a busy owner adds a step to the turn it is already paying for.
#### KV Cache effect
+8 -5
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令、PTY 工作与 subagent。读取可在配置的超时内等待,列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到会话内通知:繁忙的 agent 在下一步收到通知,空闲的 agent 则由 follow-up 轮次唤醒。配置控制等待上限、完成投递与可选的连续唤醒上限。流输出仅供单一读取方消费,待领通知无法在所有者释放后存活。
使用 `dsh-tool-jobs`,可通过 `job_output`、`job_list` 与 `job_kill` 检查和控制后台命令、PTY 工作与 subagent。读取可在配置的超时内等待,列表结果标识各任务的 kind 与状态,而取消只有在工作停止后才结算。归属明确的工作完成时,agent(智能体)会收到内联携带简短输出的会话内通知:繁忙的 agent 在下一步收到通知,空闲的 agent 则由 follow-up 轮次唤醒。配置控制等待上限、完成投递与可选的连续唤醒上限。流输出仅供单一读取方消费,待领通知无法在所有者释放后存活。
## 目录
@@ -37,7 +37,7 @@ kind: "package-reference"
### 完成通知
任务完成时,拥有它的 agent 会收到会话内消息 `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。模型已经收走的完成不再通知:注册表把释放了存活 `wait` 的结算报告为 `awaited`——无论是 `job_output` 的等待,还是 shell 工具在等待自己的前台命令——插件则记着模型经 `job_kill` 请求的杀停;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。
任务完成时,拥有它的 agent 会收到 `background job <id> (<kind>: <label>) finished [status: ...]. Output:`,其后是它尚未读取的输出;没有输出时则以 `. No new output.` 结尾。若通知会超过 `inlineOutputBytes`(默认 2048 字节)或生产方的 `outputLimitBytes`,则改以 `. Read its output with job_output.` 结尾。label 取其首行,上限 120 个字符。在通知仍待领时收走该任务的 `job_output` 调用会撤回该通知;从未抵达模型的通知会把输出留给 `job_output`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。模型已经收走的完成不再通知:注册表把释放了存活 `wait` 的结算报告为 `awaited`——无论是 `job_output` 的等待,还是 shell 工具在等待自己的前台命令——插件则记着模型经 `job_kill` 请求的杀停;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。
唤醒默认不设上限:无人值守的 agent 连续串起后台命令与一次性 subagent 时,每次完成都会唤醒它。`maxConsecutiveWakes` 可以封顶:每个所有者最多被唤醒这么多次,此后的通知降级为注入;领取任何用户撰写的消息都会恢复预算。上限用来约束自激链——被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者——但超出上限的通知会静默等到用户下一次输入,依赖唤醒完成工作的会话会停在那里。`completionDelivery: quiet` 让空闲所有者也在注入通道上,确定性 transcript(文本记录)需要的正是这一点。
@@ -55,6 +55,7 @@ kind: "package-reference"
| `maxWaitTimeoutMs` | `600,000` | 模型所给等待时间的上限;更大的值向下收敛到它 |
| `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
| `maxConsecutiveWakes` | 未设置 | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入;未设置即不封顶 |
| `inlineOutputBytes` | `2048` | 携带任务未读输出的完整通知的最大长度;`0` 表示从不内联 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-jobs)是每个受支持字段及其 JSDoc 的穷尽式真源。
@@ -82,7 +83,7 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:工具注册、结算订阅与模型杀停集合、提示词区段、输出上限 |
| [`src/index.ts`](src/index.ts) | 插件入口:工具注册、结算订阅、模型杀停集合与暂存的结算读取、提示词区段、输出上限 |
| [`src/render.ts`](src/render.ts) | 面向模型的渲染:公共投影、状态行与消耗式增量(stdout、`[stderr]` 段、丢失输出提示) |
### 输出上限
@@ -93,6 +94,8 @@ kind: "package-reference"
结算订阅(`{ owners: 'scope' }`)跳过注册表报告为 `awaited` 的结算、模型经 `job_kill` 杀停的任务、无所有者的任务与 teardown 结算,然后解析登记在拥有者会话下的 agent。`wakeup` 投递为空闲所有者开启一轮;设置了 `maxConsecutiveWakes` 时只在预算内开启,按确切 `Agent` 记录在 `WeakMap` 中,领取用户撰写的消息(`agent/inbox/claimed`)会重置该所有者的预算。繁忙的所有者——或超出所配预算的任何通知,以及 `quiet` 投递——改为注入 next-step inbox。注册表只计入结算时仍被欠着投影的等待,因此超时或中止的等待会让之后的结算照常通知;移除会把任务从模型杀停集合里删掉,该集合只保有模型杀停过的存活任务。
每条通知在结算时取走该任务唯一的一次消费式读取,并以通知的消息 id 暂存。内联通知被记录为 `user/message`(`session/event`)时,暂存的读取即被用掉;被某一步领取、但该步随后被拒绝或取消的通知不会被记录,因此其读取仍保持暂存。否则 `job_output` 会先于自己的读取返回暂存的读取,并在通知仍待领时将其从所有者的 inbox 中移除。任务移除会丢弃它。
</details>
-----
@@ -153,11 +156,11 @@ Track every background job id you start. You are notified in-session when a job
#### 模型看到什么
读取会返回输出或 `(no new output)`,随后是 `[status: <status>]` 和可选 detail。空列表返回 `(no background jobs)`。kill 返回 `requested cancellation of job <id>` 或现有终止状态。模型尚未收走且有所有者归属的完成使用上述通知。
读取会返回输出或 `(no new output)`,随后是 `[status: <status>]` 和可选 detail。空列表返回 `(no background jobs)`。kill 返回 `requested cancellation of job <id>` 或现有终止状态。模型尚未收走且有所有者归属的完成使用上述通知,并在容纳得下时附带任务的未读输出。
#### Token 影响
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量仅在设置了 `maxConsecutiveWakes` 时按所有者封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出,内联通知省去了原本随后需要的 `job_output` 一步;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量仅在设置了 `maxConsecutiveWakes` 时按所有者封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
#### KV Cache 影响
+78 -7
View File
@@ -4,14 +4,16 @@
* producers. It also delivers completions the model has not already
* collected to the owning agent: injected into a busy owner's next step, or
* opening a turn on an idle one under the default `wakeup` delivery, unbounded
* unless `maxConsecutiveWakes` caps it per owner.
* unless `maxConsecutiveWakes` caps it per owner. A notice carries the job's
* unread output when it fits `inlineOutputBytes`, and `job_output` withdraws a
* notice still pending when it returns the same output.
* @module @deepseek-ai/dsh-tool-jobs
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import type { ContextFormed, MessageId } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -54,6 +56,13 @@ export interface Config {
* the next user input.
*/
maxConsecutiveWakes?: number
/**
* Largest complete completion notice, in UTF-8 bytes, that carries the
* job's unread output (default 2048); a larger one leaves that output for
* `job_output`. The producer's `outputLimitBytes` also bounds it, and 0
* never inlines output.
*/
inlineOutputBytes?: number
}
export const Config: z<Config> = z.object({
@@ -61,6 +70,7 @@ export const Config: z<Config> = z.object({
maxWaitTimeoutMs: z.number().min(1).default(600_000),
completionDelivery: z.union(['quiet', 'wakeup'] as const).default('wakeup'),
maxConsecutiveWakes: z.number().min(1),
inlineOutputBytes: z.natural().default(2048),
})
/** Shared schema for job-control outputs. */
@@ -119,9 +129,36 @@ function completionSummary(job: JobView): string {
return boundContextSummary(`${job.kind} ${job.label} ${statusLine(publicJob(job))}`)
}
/**
* The job's label as a notice names it: the first non-empty line, bounded
* like a notice summary. The model wrote the full command and holds the job id.
* @param job - the settled job.
* @returns ` (kind: label) finished [status: …]`.
*/
function noticeDetail(job: JobView): string {
const text = job.label.trimStart()
const newline = text.indexOf('\n')
const label = boundContextSummary(newline === -1 ? text : `${text.slice(0, newline)}…`)
return ` (${job.kind}: ${label}) finished ${statusLine(publicJob(job))}`
}
/**
* The notice that carries the job's unread output, when the complete text fits.
* @param job - the settled job.
* @param output - the consuming read taken at settlement.
* @param maxBytes - the configured inline bound.
* @returns the notice text, or undefined when it exceeds either bound.
*/
function inlineCompletionNotice(job: JobView, output: string, maxBytes: number): string | undefined {
const body = output.endsWith('\n') ? output.slice(0, -1) : output
const text = `background job ${job.id}${noticeDetail(job)}${body.length === 0 ? '. No new output.' : `. Output:\n${body}`}`
const bytes = encoder.encode(text).byteLength
return bytes <= maxBytes && bytes <= (job.outputLimitBytes ?? Infinity) ? text : undefined
}
function fitCompletionNotice(job: JobView): string {
const prefix = `background job ${job.id}`
const detail = ` (${job.kind}: ${job.label}) finished ${statusLine(publicJob(job))}`
const detail = noticeDetail(job)
const action = '\nDone; job_output.'
const complete = `${prefix}${detail}. Read its output with job_output.`
const maxBytes = job.outputLimitBytes
@@ -180,6 +217,16 @@ function presentJobCall(title: string, kind: 'read' | 'execute', rawInput?: stri
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
}
/** A settlement read held for the model; see the `held` map in {@link apply}. */
interface HeldRead {
readonly owner: Agent
/** The notice that reported the settlement. */
readonly messageId: MessageId
readonly text: string
/** Whether the notice carries `text`. */
readonly inline: boolean
}
/** The consuming read as the model sees it: the delta, then the result once, then the status line. */
function readBody(read: JobRead): { text: string; job: PublicJobSnapshot } {
const delta = renderModelDelta(read.chunks, read.lossy, read.job.output.spillPaths ?? [])
@@ -194,6 +241,7 @@ export function apply(ctx: Context, config: Config): void {
const waitCap = config.maxWaitTimeoutMs ?? 600_000
const delivery = config.completionDelivery ?? 'wakeup'
const wakeBudget = config.maxConsecutiveWakes
const inlineBytes = config.inlineOutputBytes ?? 2048
// Turns this plugin opened on each owner since that owner last consumed
// human input. Keyed by the exact Agent, so a same-session replacement
@@ -207,8 +255,15 @@ export function apply(ctx: Context, config: Config): void {
if (wakeBudget !== undefined && !Number.isSafeInteger(wakeBudget)) {
throw new Error(`tool-jobs: maxConsecutiveWakes (${wakeBudget}) must be a whole number of turns`)
}
// Nothing spends the budget under quiet delivery or without a cap, so
// nothing needs to refill it.
// The consuming read each settlement notice took, until the model receives
// it: through the notice's logged `user/message` when the notice carries it
// inline, otherwise through `job_output`. A claimed notice can still miss
// the model (a rejected or cancelled step), so only the logged message
// spends it. `job_output` withdraws a notice still pending, which would only
// repeat its result.
const held = new Map<JobId, HeldRead>()
if (delivery === 'wakeup' && wakeBudget !== undefined) {
ctx.on('agent/inbox/claimed', ({ agent, message }) => {
// Claiming is the point the human's input actually enters a step; a notice
@@ -216,6 +271,12 @@ export function apply(ctx: Context, config: Config): void {
if (message.source.kind === 'user') spentWakes.delete(agent)
})
}
ctx.on('session/event', (session, event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'tool-jobs') return
for (const [id, entry] of held) {
if (entry.inline && entry.messageId === event.data.id && entry.owner.id === session.id) held.delete(id)
}
})
const outputLimits = new WeakMap<ToolExecution, number>()
ctx.on('tools/pre-execute', (exec, next) => {
@@ -272,6 +333,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.jobs.events.subscribe({ owners: 'scope' }, (event) => {
if (event.type === 'removed') {
killedByModel.delete(event.job.id)
held.delete(event.job.id)
return
}
if (event.type !== 'settled') return
@@ -282,10 +344,12 @@ export function apply(ctx: Context, config: Config): void {
// absent here when it left before settlement — and then no inbox is left.
const owner = ctx.get('agents')?.get(event.job.owner)
if (owner === undefined) return
const output = readBody(ctx.jobs.read(event.job.id, event.job.owner)).text
const inline = inlineCompletionNotice(event.job, output, inlineBytes)
const message = createUserMessage({
content: [{
type: 'text',
text: fitCompletionNotice(event.job),
text: inline ?? fitCompletionNotice(event.job),
}],
source: {
kind: 'tool-jobs',
@@ -293,6 +357,7 @@ export function apply(ctx: Context, config: Config): void {
summary: completionSummary(event.job),
},
})
held.set(event.job.id, { owner, messageId: message.id, text: output, inline: inline !== undefined })
if (delivery === 'wakeup' && owner.status === 'idle') {
if (wakeBudget === undefined) {
owner.followup(message)
@@ -344,7 +409,13 @@ export function apply(ctx: Context, config: Config): void {
// set before any later settlement, which then notifies as usual.
await jobs.wait(id, Math.min(args.timeout_ms ?? waitDefault, waitCap), exec.agent?.id, exec.signal)
}
return readBody(jobs.read(id, exec.agent?.id))
const read = readBody(jobs.read(id, exec.agent?.id))
const notice = held.get(id)
if (notice === undefined) return read
held.delete(id)
// This result repeats the notice's output, so a notice still pending goes.
if (ctx.get('agents')?.get(notice.owner.id) === notice.owner) notice.owner.inbox.remove(notice.messageId)
return { text: `${notice.text}${read.text}`, job: read.job }
},
presentCall: args => presentJobCall(`Read output from background job ${args.job_id}`, 'read', args.job_id),
}))
+127 -2
View File
@@ -6,6 +6,8 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import { JobId } from '@deepseek-ai/dsh-jobs'
@@ -63,6 +65,7 @@ async function setup(config: ToolJobs.Config = {}, registry: RegistryConfig = {}
interface FakeDelivery {
inject?: (...args: unknown[]) => void
followup?: (...args: unknown[]) => void
remove?: (...args: unknown[]) => boolean
/** Defaults to `running`, the lane that never wakes, so notice-content tests pin one lane. */
status?: 'idle' | 'running'
}
@@ -79,6 +82,7 @@ async function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery
ctx: scopeFiber.ctx,
inject: delivery.inject ?? (() => {}),
followup: delivery.followup ?? (() => {}),
inbox: { remove: delivery.remove ?? (() => true) },
status: delivery.status ?? 'running',
session: { id, header: { version: 0, id, createdAt: 0 } },
} as unknown as Agent
@@ -770,7 +774,7 @@ describe('completion notice delivery', () => {
const message = inject.mock.calls[0]![0] as { content: readonly { type: string; text: string }[] }
expect(message.content[0]!.text).toBe(
`background job ${startedId(ctx, 'bash-1')} (bash: pnpm run watch) finished `
+ '[status: killed, signal: SIGTERM; cancelled by the user]. Read its output with job_output.',
+ '[status: killed, signal: SIGTERM; cancelled by the user]. No new output.',
)
})
@@ -913,7 +917,7 @@ describe('completion notices', () => {
expect(inject).toHaveBeenCalledWith({
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: `background job ${startedId(ctx, 'bash-1')} (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with job_output.` }],
content: [{ type: 'text', text: `background job ${startedId(ctx, 'bash-1')} (bash: pnpm test) finished [status: completed, exit code: 0]. No new output.` }],
source: {
kind: 'tool-jobs',
form: 'notice',
@@ -1235,3 +1239,124 @@ describe('completion notices', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
})
})
describe('completion notice output', () => {
/** The notice message the fake owner's inject received. */
function notice(inject: ReturnType<typeof vi.fn>, index = 0): UserMessage & { content: [{ type: 'text'; text: string }] } {
return inject.mock.calls[index]![0] as UserMessage & { content: [{ type: 'text'; text: string }] }
}
/** Report the notice as logged into the owner's Session, the point it reaches the model. */
function logged(ctx: Context, owner: Agent, message: UserMessage): void {
ctx.emit('session/event', owner.session, { type: 'user/message', seq: 1, time: 0, data: message } as SessionEvent)
}
it('carries unread output that fits, and the read is spent once the notice is logged', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: 'pnpm test' })
const id = ctx.jobs.start(p.spec)
p.append('ok 3 tests\n', { channel: 'stdout' })
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (bash: pnpm test) finished [status: completed, exit code: 0]. Output:\nok 3 tests`)
logged(ctx, owner, notice(inject))
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('(no new output)\n[status: completed, exit code: 0]')
})
it('leaves larger output for job_output', async () => {
const { ctx } = await setup({ inlineOutputBytes: 40 })
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: 'pnpm test' })
const id = ctx.jobs.start(p.spec)
p.append('ok 3 tests\n', { channel: 'stdout' })
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with job_output.`)
logged(ctx, owner, notice(inject))
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('ok 3 tests\n[status: completed, exit code: 0]')
})
it('withdraws a pending notice when job_output returns the same output', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const remove = vi.fn(() => true)
const owner = await fakeAgent(ctx, 'sess-1', { inject, remove })
const p = producer({ owner: owner.id, kind: 'test-task' })
const id = ctx.jobs.start(p.spec)
p.append('partial\n')
p.settle({ status: 'completed', result: 'answer' })
await tick()
expect(text(await call(ctx, 'job_output', { job_id: id, wait: true }, owner))).toBe('partial\nanswer\n[status: completed]')
expect(remove).toHaveBeenCalledWith(notice(inject).id)
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('(no new output)\n[status: completed]')
expect(remove).toHaveBeenCalledTimes(1)
})
it('keeps the output of an inline notice that was claimed but never logged', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const remove = vi.fn(() => false)
const owner = await fakeAgent(ctx, 'sess-1', { inject, remove })
const p = producer({ owner: owner.id })
const id = ctx.jobs.start(p.spec)
p.append('lost line\n')
p.settle({ status: 'completed' })
await tick()
// A rejected or cancelled step claims the notice without logging it.
emitAgentEvent(ctx, owner, 'agent/inbox/claimed', { message: notice(inject), turn: 1 })
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('lost line\n[status: completed]')
expect(remove).toHaveBeenCalledWith(notice(inject).id)
})
it('spends only the inline read of the logged notice in its own session', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const other = await fakeAgent(ctx, 'sess-2')
const p = producer({ owner: owner.id })
const id = ctx.jobs.start(p.spec)
p.append('kept\n')
p.settle({ status: 'completed' })
await tick()
logged(ctx, other, notice(inject))
logged(ctx, owner, createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'tool-jobs', form: 'notice', summary: 'x' } }))
logged(ctx, owner, createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
ctx.emit('session/event', owner.session, { type: 'turn/start', seq: 2, time: 0, data: { turn: 1 } } as SessionEvent)
expect(text(await call(ctx, 'job_output', { job_id: id }, owner))).toBe('kept\n[status: completed]')
})
it('leaves the inbox of an owner that is no longer registered', async () => {
const { ctx } = await setup()
const remove = vi.fn(() => true)
const oldOwner = await fakeAgent(ctx, 'shared', { remove })
const p = producer({ owner: oldOwner.id })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed' })
await tick()
await detachAgent(oldOwner)
const replacement = await fakeAgent(ctx, 'shared')
expect(text(await call(ctx, 'job_output', { job_id: id }, replacement))).toBe('(no new output)\n[status: completed]')
expect(remove).not.toHaveBeenCalled()
})
it('names a multi-line label by its first line', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: "cat > a.mjs <<'EOF'\nconsole.log(1)\nEOF" })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed' })
await tick()
expect(notice(inject).content[0].text).toBe(`background job ${id} (bash: cat > a.mjs <<'EOF'…) finished [status: completed]. No new output.`)
})
})
+2 -2
View File
@@ -27,8 +27,8 @@
en: a5eecea7bb7a75e8
zh: fae7f08e0e11594e
/deepseek-ai-dsh-tool-bash/use-this-package/foreground-commands-as-jobs:
en: 5eef538028ca60d6
zh: dc1c8ec24c81d2ca
en: d6da2d08afdca324
zh: 606b7b1da7447874
/deepseek-ai-dsh-tool-bash/use-this-package/sandboxed-execution-and-escalation:
en: 472beeb895ca46cf
zh: ba110c9f62f7d5b8
+1 -1
View File
@@ -63,7 +63,7 @@ Passing `run_in_background: true` admits a job and returns its id immediately; c
### Foreground commands as jobs
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]` plus the job hand-off guidance, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the signal marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]`, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the signal marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
### Sandboxed execution and escalation
+1 -1
View File
@@ -63,7 +63,7 @@ kind: "package-reference"
### 前台命令即任务
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]` 加任务交接指引,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在信号标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]`,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在信号标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
### 沙箱执行与升权
+2 -4
View File
@@ -69,7 +69,7 @@ export function renderResult(
* Shape a foreground call that stopped waiting into the text the model sees:
* the output captured so far (one consuming registry read taken at that
* point, so `job_output` continues exactly after it), then the still-running
* marker and the job hand-off guidance.
* marker. The `tool:jobs` system-prompt section explains how to follow up.
* @param promoted - the promoted result value: the job id, the wait that
* expired, and the output so far.
* @returns the model-facing text for a promoted call.
@@ -78,9 +78,7 @@ export function renderPromoted(promoted: { jobId: string; timeoutMs: number; out
const body = promoted.output.length > 0
? promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`
: ''
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n`
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.'
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]`
}
/**
@@ -226,7 +226,7 @@ describe('foreground commands as jobs', () => {
const body = text(result)
expect(body).toContain('early-output')
expect(body).toContain(`[still running after 250ms; moved to background job ${jobId}]`)
expect(body).toContain('read newer output with job_output, stop it with job_kill')
expect(body.endsWith(`moved to background job ${jobId}]`)).toBe(true)
expect(body.indexOf('early-output')).toBeLessThan(body.indexOf('[still running'))
const jobs = ctx.jobs
@@ -453,9 +453,7 @@ describe('foreground commands as jobs', () => {
describe('renderPromoted', () => {
it('pins the promoted text with and without pre-promotion output', () => {
expect(renderPromoted({ jobId: 'bash-7', timeoutMs: 120_000, output: '' })).toBe(
'[still running after 120000ms; moved to background job bash-7]\n'
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.',
'[still running after 120000ms; moved to background job bash-7]',
)
// A partial line gains the separating newline exactly once.
expect(renderPromoted({ jobId: 'bash-7', timeoutMs: 250, output: 'partial' }))
@@ -208,7 +208,7 @@ describe('bash tool through the agent loop', () => {
expect(resultText(toolResult)).toContain('[exit code: 9]')
})
it('background: start ack → completion wakes the idle agent → job_output collects it', async () => {
it('background: start ack → completion wakes the idle agent with the output inline', async () => {
// The command blocks on a sentinel this test creates only after the agent
// has gone idle, so settlement cannot fold into the still-running turn.
// Without that fence a fast command can settle before step 2's pre-step
@@ -255,25 +255,26 @@ describe('bash tool through the agent loop', () => {
const found = events(agent).findLast(event => event.type === 'tool/result')
return found === undefined ? '' : resultText(found)
}
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('bg-ok'))
await pollUntil(() => events(agent).some(isNotice) && lastResultText().includes('[status: completed'))
// Two turns: the user's, then the one the completion opened by itself.
expect(events(agent).filter(event => event.type === 'turn/start')).toHaveLength(2)
// The notice carries the gated command as its label, so this pins the id,
// the terminal status, and the producer identity; the verbatim notice text
// and its bounding are pinned in the tool-jobs unit tests.
// The notice carries the gated command as its label and the job's output,
// so this pins the id, the terminal status, the output, and the producer
// identity; the verbatim notice text and its bounding are pinned in the
// tool-jobs unit tests.
const notice = events(agent).find(isNotice)!
const noticeText = notice.data.content
.filter(block => block.type === 'text').map(block => block.text).join('')
expect(noticeText).toContain(`background job ${jobId} (bash: `)
expect(noticeText).toContain('finished [status: completed, exit code: 0]')
expect(noticeText).toContain('finished [status: completed, exit code: 0]. Output:\nbg-ok')
expect(notice.data.source).toMatchObject({
kind: 'tool-jobs',
form: 'notice',
})
const readResult = findEvent(events(agent), 'tool/result', 'last')
expect(readResult.data.message.isError).toBe(false)
expect(resultText(readResult)).toContain('bg-ok')
expect(resultText(readResult)).toContain('[status: completed, exit code: 0]')
// The notice already delivered the output, so the read has nothing new.
expect(resultText(readResult)).toBe('(no new output)\n[status: completed, exit code: 0]')
})
})
+2 -2
View File
@@ -27,8 +27,8 @@
en: 80195c9b61b29737
zh: 0a187f3f52814b90
/deepseek-ai-dsh-tool-pwsh/use-this-package/foreground-commands-as-jobs:
en: 4a487e48ef146846
zh: 0a34dc49bffb03af
en: e2ac7a741c287b92
zh: a9e95c0a84671f45
/deepseek-ai-dsh-tool-pwsh/use-this-package/windows-specific-sandbox-behavior:
en: 76f3f791f53d1005
zh: e5b262db24e6eb83
+1 -1
View File
@@ -58,7 +58,7 @@ The tool executes `pwsh -Command <command>` and returns the combined output. Com
### Foreground commands as jobs
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]` plus the job hand-off guidance, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the exit marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
With a job registry composed, a foreground command is registered with `ctx.jobs` at its start and the call waits on that job: the command is listed, streams through `job.list` and `job.follow`, and can be stopped from the Web task list for as long as it runs. A command that finishes within the timeout returns the ordinary foreground result and its job record leaves the registry with it, so the model never sees an id. A command that outlives the timeout keeps running as the job it already was, and the call returns `[still running after <timeoutMs>ms; moved to background job <id>]`, seeded with one consuming read of the output so far — `job_output` continues exactly after it. A kill from outside the call (the human stopping the job) settles the foreground result with `[stopped: <reason>]` ahead of the exit marker, so the model reads the reason instead of a command failure; cancelling the call itself kills the job. Registration is best-effort: `promoteOnTimeout: false`, a missing job registry, or a registry that refuses the job at its start (the owner's job limit, no controller) run the command under the executor's deadline kill instead, and the `timeoutMs` parameter description advertises the hand-over only when it holds.
### Windows-specific sandbox behavior
+1 -1
View File
@@ -58,7 +58,7 @@ kind: "package-reference"
### 前台命令即任务
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]` 加任务交接指引,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在退出标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
组合中有 job 注册表时,前台命令一启动就登记到 `ctx.jobs`,调用等待该任务:命令在运行期间始终被列出、经 `job.list` 与 `job.follow` 流式观看,并可从 Web 任务列表停止。在超时内完成的命令返回普通前台结果,其任务记录随结果一起离开注册表,模型从不看到 id。超过超时仍在运行的命令继续作为它本来就是的那个任务运行,调用返回 `[still running after <timeoutMs>ms; moved to background job <id>]`,并以一次消费式读取带上目前为止的输出——`job_output` 恰好从此处接续。来自调用之外的杀停(人在界面上停止任务)会让前台结果在退出标记之前带上 `[stopped: <reason>]`,模型读到的是原因而不是命令失败;取消调用本身则杀掉任务。登记是尽力而为的:`promoteOnTimeout: false`、缺少 job 注册表,或注册表在启动时拒绝该任务(持有者的任务上限、没有控制器)都会改为在执行器的 deadline 杀下运行命令,`timeoutMs` 参数描述也只在交接语义成立时才宣传它。
### Windows 特有的沙箱行为
+2 -4
View File
@@ -89,7 +89,7 @@ export function renderPwshResult(
* Shape a foreground call that stopped waiting into the text the model sees:
* the output captured so far (one consuming registry read taken at that
* point, so `job_output` continues exactly after it), then the still-running
* marker and the job hand-off guidance.
* marker. The `tool:jobs` system-prompt section explains how to follow up.
* @param promoted - the promoted result value: the job id, the wait that
* expired, and the output so far.
* @returns the model-facing text for a promoted call.
@@ -98,9 +98,7 @@ export function renderPwshPromoted(promoted: { jobId: string; timeoutMs: number;
const body = promoted.output.length > 0
? promoted.output.endsWith('\n') ? promoted.output : `${promoted.output}\n`
: ''
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]\n`
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.'
return `${body}[still running after ${promoted.timeoutMs}ms; moved to background job ${promoted.jobId}]`
}
/**
@@ -291,7 +291,7 @@ describe('foreground commands as jobs (pwsh)', () => {
const jobId = ctx.jobs.list()[0]?.id
expect(jobId).toMatch(/^pwsh-[0-9a-f]{16}$/)
expect(body).toContain(`moved to background job ${jobId}]`)
expect(body).toContain('read newer output with job_output, stop it with job_kill')
expect(body.endsWith(`moved to background job ${jobId}]`)).toBe(true)
const jobs = ctx.jobs
const job = jobs.list()[0]
@@ -536,9 +536,7 @@ describe('foreground commands as jobs (pwsh)', () => {
describe('renderPwshPromoted', () => {
it('pins the promoted text with and without pre-promotion output', () => {
expect(renderPwshPromoted({ jobId: 'pwsh-7', timeoutMs: 120_000, output: '' })).toBe(
'[still running after 120000ms; moved to background job pwsh-7]\n'
+ 'The command keeps running in the background. You will be notified when it finishes; '
+ 'read newer output with job_output, stop it with job_kill.',
'[still running after 120000ms; moved to background job pwsh-7]',
)
expect(renderPwshPromoted({ jobId: 'pwsh-7', timeoutMs: 250, output: 'partial' }))
.toContain('partial\n[still running after 250ms; moved to background job pwsh-7]')
+2 -2
View File
@@ -63,8 +63,8 @@
en: f7cec22ba9d3fb4f
zh: 67b1d40f9b136f71
/deepseek-ai-dsh-subagent/model-experience/settlement-notice/what-the-model-sees:
en: b61ce5a34e154028
zh: 861b809745f47672
en: 2305a255f1343108
zh: a8db6c6dc7a7ce00
/deepseek-ai-dsh-subagent/model-experience/settlement-notice/token-effect:
en: f98ad834e8ce8950
zh: cf509805dd934094
+1 -1
View File
@@ -153,7 +153,7 @@ Read these pages when the package-level contract is not enough. They move from t
#### What the model sees
Parent delivery emits a user-role status notice containing the child’s nonempty final text blocks, regardless of `send_message` availability or use. External notices also state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Every parent notice includes available structured output as `Structured result: <JSON>` and the provider’s safe diagnostic as a separate text block. Empty closing text is reported as `It left no closing message.` Caller delivery emits no notice; SDK lifecycle notifications retain complete child output when cleanup succeeds.
Parent delivery emits a user-role status notice containing the child’s nonempty final text blocks. When the child's last action before that text was a message to its parent, with no input claimed and no tool call started after it, the notice replaces them with `It sent you a message just before finishing, so its closing message is omitted.`, because that closing text usually restates the message. External notices also state that further messages are unsupported. Reasoning and other nontext blocks are excluded. Every parent notice includes available structured output as `Structured result: <JSON>` and the provider’s safe diagnostic as a separate text block. Empty closing text is reported as `It left no closing message.` Caller delivery emits no notice; SDK lifecycle notifications retain complete child output when cleanup succeeds.
#### Token effect
+1 -1
View File
@@ -153,7 +153,7 @@ kind: "package-reference"
#### 模型看到什么
父级投递发送包含子级最终输出中非空文本块的 user-role 状态通知,不受 `send_message` 是否可用或是否调用影响。外部通知还说明不支持后续消息。推理及其他非文本块被排除。每条父级通知都会以 `Structured result: <JSON>` 附带可用的结构化输出,并以独立文本块附带提供方安全诊断。收尾文本为空时,通知使用 `It left no closing message.`。调用方投递不发送通知;清理成功时 SDK 生命周期通知保留完整子级输出。
父级投递发送包含子级最终输出中非空文本块的 user-role 状态通知。若子级在该文本之前的最后一个动作是向父级发送消息,且此后既未领取输入也未发起工具调用,通知会以 `It sent you a message just before finishing, so its closing message is omitted.` 替代这些文本块,因为收尾文本通常只是复述该消息。外部通知还说明不支持后续消息。推理及其他非文本块被排除。每条父级通知都会以 `Structured result: <JSON>` 附带可用的结构化输出,并以独立文本块附带提供方安全诊断。收尾文本为空时,通知使用 `It left no closing message.`。调用方投递不发送通知;清理成功时 SDK 生命周期通知保留完整子级输出。
#### Token 影响
@@ -71,6 +71,13 @@ interface ActivationBase {
* not exist, so its teardown owes the parent no settlement account.
*/
announced: boolean
/**
* Whether this child's last action before its closing text was a message to
* its direct parent: no input was claimed and no tool call started since.
* Its settlement notice then omits the closing message, which restates that
* message.
*/
relayed: boolean
}
/** Resources retained only for the selected execution kind. */
@@ -133,12 +133,15 @@ function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopR
* @param childId - durable child session id named in the notice.
* @param terminal - recorded terminal state for the settled Activation.
* @param continuable - whether the child can accept another task.
* @param relayed - whether the child's last action before its closing text was a message to the parent,
* which omits the closing message.
* @returns the durable user-message representation delivered to the parent.
*/
export function createSettlementMessage(
childId: SessionId,
terminal: SubagentResult,
continuable = true,
relayed = false,
): ReturnType<typeof createUserMessage> {
const summary = settlementSummary(childId, terminal.stopReason, continuable)
// Parent providers receive this notice as a user message and may reject
@@ -150,9 +153,11 @@ export function createSettlementMessage(
return createUserMessage({
content: [
{ type: 'text' as const, text: summary },
...closingText.length === 0
? [{ type: 'text' as const, text: 'It left no closing message.' }]
: [{ type: 'text' as const, text: 'Its closing message:' }, ...closingText],
...relayed
? [{ type: 'text' as const, text: 'It sent you a message just before finishing, so its closing message is omitted.' }]
: closingText.length === 0
? [{ type: 'text' as const, text: 'It left no closing message.' }]
: [{ type: 'text' as const, text: 'Its closing message:' }, ...closingText],
...terminal.structured !== undefined
? [{ type: 'text' as const, text: `Structured result: ${JSON.stringify(terminal.structured)}` }] : [],
...terminal.diagnostic !== undefined
+10 -1
View File
@@ -934,6 +934,7 @@ export class SubagentManager {
ownedChildren: new Set(),
observer,
announced: false,
relayed: false,
}
void activation.result.promise.catch(() => undefined)
this.resident.set(childId, activation)
@@ -948,6 +949,13 @@ export class SubagentManager {
activation.handle.agent.ctx.on('agent/inbox/inserted', wakeSettlement)
activation.handle.agent.ctx.on('agent/inbox/claimed', wakeSettlement)
activation.handle.agent.ctx.on('agent/inbox/discarded', wakeSettlement)
// Input or a tool call after the child's last message to its parent
// means the closing text may report work that message did not.
activation.handle.agent.ctx.on('agent/inbox/claimed', () => { activation.relayed = false })
activation.handle.agent.ctx.on('tools/pre-execute', (_exec, next) => {
activation.relayed = false
return next()
})
}
observer.start(activationAgent(activation))
} catch (error: unknown) {
@@ -1186,7 +1194,7 @@ export class SubagentManager {
const parent = this.ctx.agents.get(activation.parent.id)
if (parent !== activation.parent) return
const message = createSettlementMessage(
activation.childId, terminal, activationAgent(activation) !== undefined,
activation.childId, terminal, activationAgent(activation) !== undefined, activation.relayed,
)
if (this.closingTeardownFor(parent) !== undefined) {
parent.inject(message)
@@ -1299,6 +1307,7 @@ export class SubagentManager {
}
const message = createAgentMessage(sender, content)
this.sendAgentMessage(parent, message)
activation.relayed = true
return message.id
}
@@ -67,3 +67,15 @@ it('includes closing text alongside structured results and diagnostics', () => {
{ type: 'text', text: 'safe diagnostic' },
])
})
it('omits the closing text after the child messaged its parent, keeping structured results and diagnostics', () => {
expect(createSettlementMessage(childId, {
stopReason: 'completed', output: [{ type: 'text', text: 'final answer, restated' }],
structured: { answer: 42 }, diagnostic: 'safe diagnostic',
}, true, true).content).toEqual([
summary,
{ type: 'text', text: 'It sent you a message just before finishing, so its closing message is omitted.' },
{ type: 'text', text: 'Structured result: {"answer":42}' },
{ type: 'text', text: 'safe diagnostic' },
])
})
@@ -2985,9 +2985,79 @@ describe('continuable settlement delivery', () => {
await waitNoActivation(ctx, started.childId)
// The contract is unconditional precisely so the parent-side tool
// description can promise it; bookkeeping "did it report?" would make the
// promise conditional on a channel this manager does not own.
// description can promise it; only the closing text, which restates the
// message, is left out.
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
expect(settlementNotices(parent)[0]!.text).toBe(
`Background subagent ${started.childId} finished and will do no further work unless you send it more.\n`
+ 'It sent you a message just before finishing, so its closing message is omitted.',
)
})
it('reports the closing message again when the child received input after its message', async () => {
const release = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: textResponse('interim'), gate: release.promise },
{ chunks: textResponse('parent ack') },
{ chunks: textResponse('the answer') },
{ chunks: textResponse('parent ack') },
])
const { ctx, parent } = await setupWith(adapter)
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
expect(adapter.requests).toHaveLength(1)
return live!
})
await ctx.subagents.sendMessage(child, parent.id, message('an interim finding'), { signal: testSignal })
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
await parent.whenIdle()
await ctx.subagents.sendMessage(parent, started.childId, message('now finish'), { signal: testSignal })
release.resolve(undefined)
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
expect(settlementNotices(parent)[0]!.text).toBe(
`Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`,
)
})
it('reports the closing message when the child ran a tool after its message', async () => {
const release = Promise.withResolvers<undefined>()
const adapter = new GatedAdapter([
{ chunks: toolCallResponse('t1', 'noop', {}), gate: release.promise },
{ chunks: textResponse('parent ack') },
{ chunks: textResponse('the answer') },
{ chunks: textResponse('parent ack') },
])
const { ctx, parent } = await setupWith(adapter)
ctx.tools.register(defineTool({
name: 'noop',
description: 'does nothing',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: false, properties: {} },
render: () => [{ type: 'text', text: 'noop' }],
},
execute: () => Promise.resolve({}),
}))
const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' })
const child = await vi.waitFor(() => {
const live = ctx.agents.get(started.childId)
expect(live).toBeDefined()
expect(adapter.requests).toHaveLength(1)
return live!
})
await ctx.subagents.sendMessage(child, parent.id, message('an interim finding'), { signal: testSignal })
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) })
release.resolve(undefined)
await waitNoActivation(ctx, started.childId)
await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) })
expect(settlementNotices(parent)[0]!.text).toBe(
`Background subagent ${started.childId} finished and will do no further work unless you send it more.\nIts closing message:\nthe answer`,
)
})
it('delivers the terminal reason when the child never had a chance to report', async () => {
@@ -22,13 +22,13 @@
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Agent {{session:2}} sent a message: "},{"type":"text","text":"CHILD_MESSAGE_OK"}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Message sent."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"It sent you a message just before finishing, so its closing message is omitted."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"}]}}
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Agent {{session:2}} sent a message: "},{"type":"text","text":"CHILD_MESSAGE_OK"}],"source":{"kind":"agent-message","form":"relay","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"Message sent."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{session:2}} finished and will do no further work unless you send it more."},{"type":"text","text":"It sent you a message just before finishing, so its closing message is omitted."}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{session:2}} finished and will do no further work unless you send it more.","senderSessionId":"{{session:2}}"},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1788880390979,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788880390979,"index":0,"dt":[],"texts":["SUBAGENT_SETTLED_NOTED"]},{"type":"chunk","time":1788880390979,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}},{"type":"chunk","time":1788880390979,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1788880390979,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -16,12 +16,12 @@
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"async-confinement-start","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > unexpected-command.txt\",\"description\":\"Start a background command under confinement\",\"run_in_background\":true}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"printf SHOULD_NOT_RUN > unexpected-command.txt","startedAt":1791434082502,"toolCall":{"callId":"async-confinement-start","rootCallId":"async-confinement-start"}}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"printf SHOULD_NOT_RUN > unexpected-command.txt","startedAt":1791434082502,"toolCall":{"callId":"async-confinement-start","rootCallId":"async-confinement-start"},"finishedAt":1791434082504,"status":"failed","cause":"producer","producerReported":true,"detail":"sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. Read its output with job_output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. No new output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"}]}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"async-confinement-start"},"toolCallId":"async-confinement-start","content":[{"type":"text","text":"started background job {{id:1}}"}],"isError":false,"id":"{{message:6}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. Read its output with job_output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"background job {{id:1}} (bash: printf SHOULD_NOT_RUN > unexpected-command.txt) finished [status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]. No new output."}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash printf SHOULD_NOT_RUN > unexpected-command.txt [status: failed, sandbox mode \"read-only\" is requested but no sandb…"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"async-confinement-result","name":"job_output","arguments":"{\"job_id\":\"{{id:1}}\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791434082516,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791434082516,"index":0,"dt":[],"id":"async-confinement-result","name":"job_output","args":["{\"job_id\":\"{{id:1}}\",\"wait\":true}"]},{"type":"chunk","time":1791434082516,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"async-confinement-result","name":"job_output","arguments":"{\"job_id\":\"{{id:1}}\",\"wait\":true}"}}},{"type":"chunk","time":1791434082516,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791434082516,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"async-confinement-result","name":"job_output","arguments":"{\"job_id\":\"{{id:1}}\",\"wait\":true}"}}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"async-confinement-result"},"toolCallId":"async-confinement-result","content":[{"type":"text","text":"(no new output)\n[status: failed, sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: fixture asynchronous confinement refused]"}],"isError":false,"id":"{{message:8}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}
@@ -0,0 +1,118 @@
[
{
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "tool-call"
},
{
"type": "tool-call-delta",
"index": 0,
"id": "notice-start",
"name": "bash",
"argumentsDelta": "{\"command\": \"sleep 1; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "tool-call",
"id": "notice-start",
"name": "bash",
"arguments": "{\"command\": \"sleep 1; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 10,
"outputTokens": 5
}
},
{
"type": "finish",
"reason": {
"kind": "tool-calls"
}
}
]
},
{
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "tool-call"
},
{
"type": "tool-call-delta",
"index": 0,
"id": "notice-wait",
"name": "bash",
"argumentsDelta": "{\"command\": \"sleep 4\", \"description\": \"Keep working while the job finishes\"}"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "tool-call",
"id": "notice-wait",
"name": "bash",
"arguments": "{\"command\": \"sleep 4\", \"description\": \"Keep working while the job finishes\"}"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 10,
"outputTokens": 5
}
},
{
"type": "finish",
"reason": {
"kind": "tool-calls"
}
}
]
},
{
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "text"
},
{
"type": "text-delta",
"index": 0,
"text": "NOTICE_OUTPUT"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "text",
"text": "NOTICE_OUTPUT"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 10,
"outputTokens": 2
}
},
{
"type": "finish",
"reason": {
"kind": "stop"
}
}
]
}
]
@@ -0,0 +1,34 @@
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783352050748,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start sleep 1; printf 'NOTICE_OUTPUT\\n' as a background bash job, then run sleep 4 in the foreground. When the job's completion notice arrives, reply with exactly the output it reported and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Start sleep 1; printf 'NOTICE_OUTPUT\\n' as a background bash job, then run sleep 4 in the foreground. When the job's completion notice arrives, reply with exactly the output it reported and stop."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Start sleep 1; printf 'NOTICE_OUTPUT\\n'","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"notice-start","name":"bash","arguments":"{\"command\": \"sleep 1; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791807726901,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791807726901,"index":0,"dt":[],"id":"notice-start","name":"bash","args":["{\"command\": \"sleep 1; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"]},{"type":"chunk","time":1791807726901,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"notice-start","name":"bash","arguments":"{\"command\": \"sleep 1; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"}}},{"type":"chunk","time":1791807726901,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791807726901,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"notice-start","name":"bash","arguments":"{\"command\": \"sleep 1; printf 'NOTICE_OUTPUT\\\\n'\", \"description\": \"Start a short background command\", \"run_in_background\": true}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 1; printf 'NOTICE_OUTPUT\\n'","startedAt":1791807726914,"toolCall":{"callId":"notice-start","rootCallId":"notice-start"}}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"notice-start"},"toolCallId":"notice-start","content":[{"type":"text","text":"started background job {{id:1}}"}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"notice-wait","name":"bash","arguments":"{\"command\": \"sleep 4\", \"description\": \"Keep working while the job finishes\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791807726930,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1791807726930,"index":0,"dt":[],"id":"notice-wait","name":"bash","args":["{\"command\": \"sleep 4\", \"description\": \"Keep working while the job finishes\"}"]},{"type":"chunk","time":1791807726930,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"notice-wait","name":"bash","arguments":"{\"command\": \"sleep 4\", \"description\": \"Keep working while the job finishes\"}"}}},{"type":"chunk","time":1791807726930,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791807726930,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"notice-wait","name":"bash","arguments":"{\"command\": \"sleep 4\", \"description\": \"Keep working while the job finishes\"}"}}
{"type":"job/registered","data":{"jobId":"{{id:2}}","owner":"{{session:1}}","kind":"bash","label":"sleep 4","startedAt":1791807726938,"toolCall":{"callId":"notice-wait","rootCallId":"notice-wait"}}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 1; printf 'NOTICE_OUTPUT\\n'","startedAt":1791807726914,"toolCall":{"callId":"notice-start","rootCallId":"notice-start"},"finishedAt":1791807727923,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job {{id:1}} (bash: sleep 1; printf 'NOTICE_OUTPUT\\n') finished [status: completed, exit code: 0]. Output:\nNOTICE_OUTPUT"}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash sleep 1; printf 'NOTICE_OUTPUT\\n' [status: completed, exit code: 0]"},"role":"user","id":"{{message:7}}"}]}}
{"type":"job/settled","data":{"jobId":"{{id:2}}","owner":"{{session:1}}","kind":"bash","label":"sleep 4","startedAt":1791807726938,"toolCall":{"callId":"notice-wait","rootCallId":"notice-wait"},"finishedAt":1791807729950,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"notice-wait"},"toolCallId":"notice-wait","content":[{"type":"text","text":"(no output)"}],"isError":false,"id":"{{message:8}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":3}}
{"type":"user/message","data":{"content":[{"type":"text","text":"background job {{id:1}} (bash: sleep 1; printf 'NOTICE_OUTPUT\\n') finished [status: completed, exit code: 0]. Output:\nNOTICE_OUTPUT"}],"source":{"kind":"tool-jobs","form":"notice","summary":"bash sleep 1; printf 'NOTICE_OUTPUT\\n' [status: completed, exit code: 0]"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"NOTICE_OUTPUT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1791807729968,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791807729968,"index":0,"dt":[],"texts":["NOTICE_OUTPUT"]},{"type":"chunk","time":1791807729968,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"NOTICE_OUTPUT"}}},{"type":"chunk","time":1791807729968,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1791807729968,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":3}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,10 @@
version: 1
scenario: background-job-notice
profile: headless
composition: default
recording: authored
header:
class: default
replay:
override: true
platform: posix
@@ -15,7 +15,7 @@
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663346,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791526663346,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663346,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"bash-startup-timeout","name":"bash","arguments":"{\"command\":\"sleep 30\",\"timeoutMs\":1,\"description\":\"Cancel a command during startup\"}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{session:1}}","kind":"bash","label":"sleep 30","startedAt":1791526663354,"toolCall":{"callId":"bash-startup-timeout","rootCallId":"bash-startup-timeout"}}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"bash-startup-timeout"},"toolCallId":"bash-startup-timeout","content":[{"type":"text","text":"[still running after 1ms; moved to background job {{id:1}}]\nThe command keeps running in the background. You will be notified when it finishes; read newer output with job_output, stop it with job_kill."}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"bash-startup-timeout"},"toolCallId":"bash-startup-timeout","content":[{"type":"text","text":"[still running after 1ms; moved to background job {{id:1}}]"}],"isError":false,"id":"{{message:5}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":1,"outputTokens":1},"stream":[{"type":"chunk","time":1791526663368,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791526663368,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}},{"type":"chunk","time":1791526663368,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}