@deepseek-ai/dsh-tool-jobs 0.0.1-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, DeepSeek
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/jobs/tool-jobs/README.md
5
+ README.md: 8d6a00651f69258d764904ac99d000c2055982dc
6
+ README.zh.md: 15903b6f263fe9d225c48bf567c46f587d23cb00
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @deepseek-ai/dsh-tool-jobs
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ The model-facing controller for `ctx.jobs`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the controller required by `ctx.jobs.start()`.
6
+
7
+ ## Tools
8
+
9
+ - `job_output(job_id, wait?, timeout_ms?)` reads without blocking by default. Stream jobs return only the next delta; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. `wait: true` waits up to the configured cap and leaves a still-running job alive on timeout.
10
+ - `job_list()` returns caller-visible jobs as `<id> [<kind>] <status> — <label>`.
11
+ - `job_kill(job_id, reason?)` requests cancellation immediately and forwards the logged reason. Terminal jobs return a non-consuming snapshot.
12
+
13
+ All three use generic UI cards: `read` for output and list, `execute` for kill.
14
+
15
+ Their canonical values are `{ text, job }`, `PublicJobSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', job }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above.
16
+
17
+ When a producer supplies `outputLimitBytes`, `job_output`, terminal `job_kill`, and completion notices cap the complete Native UTF-8 result after adding status or notice text. Reads retain the output tail and control suffix when they fit; a bounded completion notice instead reserves `background job <id>` and the `job_output` collection instruction before spending remaining bytes on its variable kind, label, status, detail, and truncation marker. A prepended pre-execute listener captures the caller-visible job before policy, and each job-control definition's final-content callback applies its producer cap to single-text denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks; structured multi-block policy results retain their shape. An existing producer truncation marker is reused rather than duplicated. Producers that omit the field retain the existing unbounded controller behavior.
18
+
19
+ ## Completion notices
20
+
21
+ An unreported completion delivers `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` to the exact owner. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice, as does the teardown cancel that drains an owner or the service.
22
+
23
+ Which lane carries it depends on what the owner is doing. A busy owner is injected: the notice joins the next-step inbox, and the turn cannot close while that inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle owner is instead woken with a follow-up turn, because a pending notice nothing claims is a completion the model never learns about. `completionDelivery: quiet` keeps the injection lane for idle owners too, which is what a deterministic transcript needs.
24
+
25
+ Waking is bounded. Each owner may open `maxConsecutiveWakes` turns this way before further notices degrade to injection, and claiming any user-authored message restores the budget. The bound exists because the chain is self-exciting: a woken turn may start the background job whose completion wakes it again. Notices this plugin queued never refill the budget they spent.
26
+
27
+ One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's controller serves: an agent whose composition loads no `tool-jobs` cannot start background work at all.
28
+
29
+ ## Config
30
+
31
+ | key | default | meaning |
32
+ |---|---|---|
33
+ | `waitTimeoutMs` | `30000` | wait used when `wait: true` omits `timeout_ms` |
34
+ | `maxWaitTimeoutMs` | `600000` | cap for model-supplied waits |
35
+ | `completionDelivery` | `wakeup` | `wakeup` opens a turn on an idle owner; `quiet` leaves the notice pending |
36
+ | `maxConsecutiveWakes` | `3` | turns one owner may open by wake before notices degrade to injection |
37
+
38
+ A default above the cap fails at load.
39
+
40
+ ## Model Experience
41
+
42
+ ### System prompt
43
+
44
+ #### What the model sees
45
+
46
+ Every request in this plugin's registration scope contains this guidance. Agent-scoped tool filtering may hide the tools without removing the independently registered prompt section.
47
+
48
+ ##### Background-job guidance
49
+
50
+ ```markdown
51
+ Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
52
+ ```
53
+
54
+ #### Token effect
55
+
56
+ Small fixed input cost per request while active.
57
+
58
+ #### KV Cache effect
59
+
60
+ Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section.
61
+
62
+ ### Tool schemas
63
+
64
+ #### What the model sees
65
+
66
+ The generated [`job_output`, `job_list`, and `job_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-jobs) while this tool set is visible.
67
+
68
+ #### Token effect
69
+
70
+ Fixed schema cost on each request where the tools are visible.
71
+
72
+ #### KV Cache effect
73
+
74
+ Prefix-stable while tool definitions and visibility are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
75
+
76
+ ### Results and notices
77
+
78
+ #### What the model sees
79
+
80
+ 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. Unreported owned completion uses the notice above.
81
+
82
+ #### Token effect
83
+
84
+ 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 by `maxConsecutiveWakes`; a notice reaching a busy owner adds a step to the turn it is already paying for.
85
+
86
+ #### KV Cache effect
87
+
88
+ Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
89
+
90
+ ## Known Limitations and Deferred Work
91
+
92
+ - **A settlement inside the driver's retirement window still strands its notice** — between the turn loop's last inbox check and the driver committing its idle phase the owner still reads as busy, so the notice is injected and nothing wakes. Steering has the same hole; closing it belongs to `agent-loop`.
93
+ - **A spent wake budget is not restored by time** — only user-authored input refills it, so an unattended agent whose budget ran out collects its remaining notices on the next turn something else opens.
94
+ - **A notice pending on an idle owner does not survive that owner's disposal** — the disposal cancel clears the unclaimed inbox, and the log keeps the insert/cancel pair as the record.
95
+ - **Stream reads are single-consumer** — independent observers need another runtime API.
96
+ - **Unowned jobs have no session fence** — external callers must supply policy or avoid them.
package/README.zh.md ADDED
@@ -0,0 +1,96 @@
1
+ # @deepseek-ai/dsh-tool-jobs
2
+
3
+ [English](README.md) | 中文
4
+
5
+ `ctx.jobs` 的面向模型控制器:三个与 kind 无关的工具、完成通知和一个后台工作提示词区段。加载该插件会附加 `ctx.jobs.start()` 所要求的控制器。
6
+
7
+ ## 工具
8
+
9
+ - `job_output(job_id, wait?, timeout_ms?)` 默认以非阻塞方式读取。流任务只返回下一个增量;最终输出任务在终止后返回结果。每个响应都以 `[status: ...]` 结尾。`wait: true` 最多等待到配置上限,超时时仍让运行中的任务保持存活。
10
+ - `job_list()` 以 `<id> [<kind>] <status> — <label>` 返回调用方可见的任务。
11
+ - `job_kill(job_id, reason?)` 立即请求取消并转发已记录的原因。终止任务返回非消费式快照。
12
+
13
+ 三个工具都使用通用 UI 卡片:output 和 list 使用 `read`,kill 使用 `execute`。
14
+
15
+ 它们的规范值依次为 `{ text, job }`、`PublicJobSnapshot[]` 和 `{ outcome: 'cancellation-requested' | 'already-finished', job }`。公共快照携带 id、kind、label、status/detail 及开始/结束时间;它有意省略 `ownerSession` 和内部 `reported` 通知位。原生 renderer 保留上述状态与确认文本。
16
+
17
+ 当生产方提供 `outputLimitBytes` 时,`job_output`、针对已终止任务的 `job_kill` 和完成通知会在添加状态或通知文本后,对完整的原生 UTF-8 结果施加上限。只要能够容纳,读取就会保留输出尾部与控制后缀;有界完成通知则先为 `background job <id>` 和 `job_output` 收集指令预留空间,再把剩余字节用于可变的 kind、label、status、detail 与截断标记。一个前置 pre-execute 监听器会在策略运行前捕获调用方可见任务;每个任务控制定义的 final-content 回调会把其生产方上限应用到单文本拒绝、短路、规范化工具或流水线失败、替换和阻止;结构化多块策略结果保持自身形状。已有的生产方截断标记会复用,不会重复添加。省略该字段的生产方保留现有的无界控制器行为。
18
+
19
+ ## 完成通知
20
+
21
+ 一项尚未报告的完成会把 `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` 交付给确切所有者。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。kill 或针对已终止任务的 read/wait 会把交付标为已报告并抑制重复通知;排空 owner 或服务的 teardown 取消同样如此。
22
+
23
+ 由哪条通道承载取决于所有者当时在做什么。繁忙的所有者走注入:通知进入 next-step inbox,而该 inbox 尚有内容时 turn 无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的所有者则被 follow-up 唤醒,因为无人领取的待发通知等于模型永远不会知道的完成。`completionDelivery: quiet` 让空闲所有者也留在注入通道上,确定性 transcript 需要的正是这一点。
24
+
25
+ 唤醒是有界的。每个所有者最多可通过唤醒开启 `maxConsecutiveWakes` 轮,此后的通知降级为注入;领取任何用户撰写的消息都会恢复该预算。设界是因为这条链会自激:被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者。本插件自己排队的通知永远不会补充它刚花掉的预算。
26
+
27
+ 一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制器服务哪些 agent:组合中未加载 `tool-jobs` 的 agent 根本无法启动后台工作。
28
+
29
+ ## 配置
30
+
31
+ | key | 默认值 | 含义 |
32
+ |---|---|---|
33
+ | `waitTimeoutMs` | `30000` | `wait: true` 省略 `timeout_ms` 时使用的等待时间 |
34
+ | `maxWaitTimeoutMs` | `600000` | 模型所给等待时间的上限 |
35
+ | `completionDelivery` | `wakeup` | `wakeup` 为空闲所有者开启一轮;`quiet` 让通知继续待领 |
36
+ | `maxConsecutiveWakes` | `3` | 一个所有者可由唤醒开启的轮数,超出后通知降级为注入 |
37
+
38
+ 默认值高于上限时,插件会在加载时失败。
39
+
40
+ ## 模型体验
41
+
42
+ ### 系统提示词
43
+
44
+ #### 模型看到的内容
45
+
46
+ 该插件注册 scope 中的每次请求都包含以下指引。按 agent(智能体)scope 过滤工具时,可能会隐藏工具,却不会移除独立注册的提示词区段。
47
+
48
+ ##### 后台任务指引
49
+
50
+ ```markdown
51
+ Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
52
+ ```
53
+
54
+ #### Token 影响
55
+
56
+ 激活期间,每次请求都会产生少量固定的输入 token 开销。
57
+
58
+ #### KV Cache 影响
59
+
60
+ 只要插件 scope 与指引文本不变,前缀就保持稳定。激活或释放可能使从该提示词区段起的复用失效。
61
+
62
+ ### 工具 schema
63
+
64
+ #### 模型看到的内容
65
+
66
+ 该工具集可见时,会看到生成的 [`job_output`、`job_list` 和 `job_kill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-jobs)。
67
+
68
+ #### Token 影响
69
+
70
+ 工具可见时,每次请求都会产生固定的 schema token 开销。
71
+
72
+ #### KV Cache 影响
73
+
74
+ 只要工具定义与可见性不变,前缀就保持稳定。注册生命周期或 scope 限制可能使从第一个发生变化的 schema token 起的复用失效。
75
+
76
+ ### 结果与通知
77
+
78
+ #### 模型看到的内容
79
+
80
+ 读取会返回输出或 `(no new output)`,随后是 `[status: <status>]` 和可选 detail。空列表返回 `(no background jobs)`。kill 返回 `requested cancellation of job <id>` 或现有终止状态。尚未报告且有 owner 的任务完成时使用上述通知。
81
+
82
+ #### Token 影响
83
+
84
+ 结果与通知在压缩(compaction)前保留于父级历史。流读取不会重复已消费的输出;生产方提供的 `outputLimitBytes` 会限制每次完整读取或通知。在 `wakeup` 下,抵达空闲所有者的通知还会额外买下一次用户并未要求的模型请求,其数量按所有者由 `maxConsecutiveWakes` 封顶;抵达繁忙所有者的通知则只是给它已经在支付的那一轮加一步。
85
+
86
+ #### KV Cache 影响
87
+
88
+ 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
89
+
90
+ ## 已知限制与暂缓事项
91
+
92
+ - **落在 driver 退休窗口内的结算仍会让通知搁浅**:在轮次循环最后一次检查 inbox 与 driver 提交 idle 相位之间,所有者读起来仍是繁忙,因此通知走注入且无人唤醒。steer 有同样的洞;堵上它属于 `agent-loop`。
93
+ - **已花掉的唤醒预算不会随时间恢复**:只有用户撰写的输入才能补充,因此预算耗尽的无人值守 agent 要等到其他原因开启下一轮时才收走剩余通知。
94
+ - **待领于空闲所有者的通知无法在该所有者释放后存活**:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。
95
+ - **流读取只有单一消费方**:独立观察者需要另一套运行时 API。
96
+ - **无 owner 的任务没有会话隔离**:外部调用方必须提供策略或避开这些任务。
package/lib/index.js ADDED
@@ -0,0 +1,353 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
3
+ import { TextRetainer } from "@deepseek-ai/dsh-output-retention";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import { JobId } from "@deepseek-ai/dsh-jobs";
6
+ //#region lib/types/index.js
7
+ /**
8
+ * Model-facing `job_output`, `job_list`, and `job_kill` tools over
9
+ * `ctx.jobs`. Loading the plugin attaches the controller required by
10
+ * producers. It also delivers unreported completions to the owning agent:
11
+ * injected into a busy owner's next step, or opening a turn on an idle one
12
+ * under the default `wakeup` delivery, bounded per owner.
13
+ * @module @deepseek-ai/dsh-tool-jobs
14
+ */
15
+ const name = "tool-jobs";
16
+ const inject = [
17
+ "tools",
18
+ "jobs",
19
+ "systemPrompt"
20
+ ];
21
+ const Config = z.object({
22
+ waitTimeoutMs: z.number().min(1).default(3e4),
23
+ maxWaitTimeoutMs: z.number().min(1).default(6e5),
24
+ completionDelivery: z.union(["quiet", "wakeup"]).default("wakeup"),
25
+ maxConsecutiveWakes: z.number().min(1).default(3)
26
+ });
27
+ /** Shared schema for job-control outputs. */
28
+ const PUBLIC_TASK_SCHEMA = {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ properties: {
32
+ id: {
33
+ type: "string",
34
+ required: true
35
+ },
36
+ kind: {
37
+ type: "string",
38
+ required: true
39
+ },
40
+ label: {
41
+ type: "string",
42
+ required: true
43
+ },
44
+ status: {
45
+ type: "string",
46
+ required: true,
47
+ enum: [
48
+ "running",
49
+ "stopping",
50
+ "completed",
51
+ "killed",
52
+ "failed"
53
+ ]
54
+ },
55
+ detail: { type: "string" },
56
+ startedAt: {
57
+ type: "integer",
58
+ required: true
59
+ },
60
+ finishedAt: { type: "integer" }
61
+ }
62
+ };
63
+ /** Remove job ownership and notification bookkeeping from a registry snapshot. */
64
+ function publicJob(snapshot) {
65
+ return {
66
+ id: snapshot.id,
67
+ kind: snapshot.kind,
68
+ label: snapshot.label,
69
+ status: snapshot.status,
70
+ ...snapshot.detail !== void 0 ? { detail: snapshot.detail } : {},
71
+ startedAt: snapshot.startedAt,
72
+ ...snapshot.finishedAt !== void 0 ? { finishedAt: snapshot.finishedAt } : {}
73
+ };
74
+ }
75
+ /**
76
+ * Render generic status with optional producer detail.
77
+ * @param snapshot - job state to render.
78
+ * @returns a bracketed status line.
79
+ */
80
+ function statusLine(snapshot) {
81
+ return snapshot.detail !== void 0 ? `[status: ${snapshot.status}, ${snapshot.detail}]` : `[status: ${snapshot.status}]`;
82
+ }
83
+ const encoder = new TextEncoder();
84
+ function retainTail(text, maxBytes) {
85
+ const retainer = new TextRetainer({
86
+ kind: "tail",
87
+ maxBytes
88
+ });
89
+ retainer.push(text);
90
+ return retainer.finish().text;
91
+ }
92
+ function retainHead(text, maxBytes) {
93
+ const retainer = new TextRetainer({
94
+ kind: "head",
95
+ maxBytes
96
+ });
97
+ retainer.push(text);
98
+ return retainer.finish().text;
99
+ }
100
+ function fitWithSuffix(content, suffix, maxBytes, omitted) {
101
+ const complete = `${content}${suffix}`;
102
+ if (maxBytes === void 0 || encoder.encode(complete).byteLength <= maxBytes) return complete;
103
+ const fixed = `${content.endsWith(omitted.trimStart()) ? "" : omitted}${suffix}`;
104
+ const fixedBytes = encoder.encode(fixed).byteLength;
105
+ if (fixedBytes >= maxBytes) return retainTail(fixed, maxBytes);
106
+ return `${retainTail(content, maxBytes - fixedBytes)}${fixed}`;
107
+ }
108
+ /**
109
+ * One-line account of a settled job for the `notice` form's collapsed row.
110
+ * @param snapshot - the settled job.
111
+ * @returns its kind, label, and status, bounded like every notice summary.
112
+ */
113
+ function completionSummary(snapshot) {
114
+ return boundContextSummary(`${snapshot.kind} ${snapshot.label} ${statusLine(snapshot)}`);
115
+ }
116
+ function fitCompletionNotice(snapshot) {
117
+ const prefix = `background job ${snapshot.id}`;
118
+ const detail = ` (${snapshot.kind}: ${snapshot.label}) finished ${statusLine(snapshot)}`;
119
+ const action = "\nDone; job_output.";
120
+ const complete = `${prefix}${detail}. Read its output with job_output.`;
121
+ const maxBytes = snapshot.outputLimitBytes;
122
+ if (maxBytes === void 0 || encoder.encode(complete).byteLength <= maxBytes) return complete;
123
+ const omitted = "\n[notice truncated]";
124
+ const fixed = `${prefix}${omitted}${action}`;
125
+ const fixedBytes = encoder.encode(fixed).byteLength;
126
+ if (fixedBytes <= maxBytes) return fixedBytes === maxBytes ? fixed : `${prefix}${retainHead(detail, maxBytes - fixedBytes)}${omitted}${action}`;
127
+ const compact = `${prefix}${action}`;
128
+ if (encoder.encode(compact).byteLength <= maxBytes) return compact;
129
+ const actionBytes = encoder.encode(action).byteLength;
130
+ if (actionBytes >= maxBytes) return retainTail(action, maxBytes);
131
+ return `${retainHead(prefix, maxBytes - actionBytes)}${action}`;
132
+ }
133
+ function rawSingleText(content) {
134
+ if (content.length !== 1) return void 0;
135
+ const block = content[0];
136
+ if (block?.type !== "text") return void 0;
137
+ return block.text;
138
+ }
139
+ function boundSingleText(content, maxBytes) {
140
+ const text = rawSingleText(content);
141
+ if (text === void 0) return void 0;
142
+ return [{
143
+ type: "text",
144
+ text: fitWithSuffix(text, "", maxBytes, "\n[result truncated]")
145
+ }];
146
+ }
147
+ function visibleOutputLimit(ctx, exec) {
148
+ if (exec.name !== "job_output" && exec.name !== "job_kill") return void 0;
149
+ const jobId = exec.arguments?.job_id;
150
+ if (typeof jobId !== "string" || jobId.length === 0) return void 0;
151
+ return ctx.jobs.list(exec.agent).find((snapshot) => snapshot.id === jobId)?.outputLimitBytes;
152
+ }
153
+ /** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
154
+ function validateJobId(value) {
155
+ if (value.length === 0) throw new Error(`invalid job_id: expected a non-empty string, got ${JSON.stringify(value)}`);
156
+ return JobId(value);
157
+ }
158
+ /** Pending presentation shared by the three generic job controls. */
159
+ function presentTaskCall(title, kind, rawInput) {
160
+ return {
161
+ card: "generic",
162
+ title,
163
+ kind,
164
+ ...rawInput !== void 0 ? { rawInput } : {}
165
+ };
166
+ }
167
+ function apply(ctx, config) {
168
+ const waitDefault = config.waitTimeoutMs ?? 3e4;
169
+ const waitCap = config.maxWaitTimeoutMs ?? 6e5;
170
+ const delivery = config.completionDelivery ?? "wakeup";
171
+ const wakeBudget = config.maxConsecutiveWakes ?? 3;
172
+ const spentWakes = /* @__PURE__ */ new WeakMap();
173
+ if (waitDefault > waitCap) throw new Error(`tool-jobs: waitTimeoutMs (${waitDefault}) exceeds maxWaitTimeoutMs (${waitCap})`);
174
+ if (!Number.isSafeInteger(wakeBudget)) throw new Error(`tool-jobs: maxConsecutiveWakes (${wakeBudget}) must be a whole number of turns`);
175
+ if (delivery === "wakeup") ctx.on("agent/inbox/claimed", ({ agent, message }) => {
176
+ if (message.source.kind === "user") spentWakes.delete(agent);
177
+ });
178
+ const outputLimits = /* @__PURE__ */ new WeakMap();
179
+ ctx.on("tools/pre-execute", (exec, next) => {
180
+ const maxBytes = visibleOutputLimit(ctx, exec);
181
+ if (maxBytes !== void 0) outputLimits.set(exec, maxBytes);
182
+ return next();
183
+ }, { prepend: true });
184
+ const finalizeTaskContent = (exec, result) => {
185
+ const maxBytes = outputLimits.get(exec) ?? visibleOutputLimit(ctx, exec);
186
+ outputLimits.delete(exec);
187
+ if (maxBytes === void 0) return void 0;
188
+ if (exec.name === "job_output" && !result.isError) {
189
+ const value = result.value;
190
+ const body = value.text.length > 0 ? value.text : "(no new output)";
191
+ const content = body.endsWith("\n") ? body.slice(0, -1) : body;
192
+ const suffix = `\n${statusLine(value.job)}`;
193
+ if (rawSingleText(result.content) === `${content}${suffix}`) return [{
194
+ type: "text",
195
+ text: fitWithSuffix(content, suffix, maxBytes, "\n[output truncated]")
196
+ }];
197
+ }
198
+ return boundSingleText(result.content, maxBytes);
199
+ };
200
+ ctx.jobs.attachController("tool-jobs");
201
+ ctx.systemPrompt.section({
202
+ name: "tool:jobs",
203
+ order: 106,
204
+ text: "Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering."
205
+ });
206
+ ctx.jobs.onJobDone((snapshot, owner) => {
207
+ if (snapshot.reported || owner === void 0) return;
208
+ const message = createUserMessage({
209
+ content: [{
210
+ type: "text",
211
+ text: fitCompletionNotice(snapshot)
212
+ }],
213
+ source: {
214
+ kind: "plugin",
215
+ plugin: "tool-jobs",
216
+ form: "notice",
217
+ summary: completionSummary(snapshot)
218
+ }
219
+ });
220
+ const spent = spentWakes.get(owner) ?? 0;
221
+ if (delivery === "wakeup" && owner.status === "idle" && spent < wakeBudget) {
222
+ spentWakes.set(owner, spent + 1);
223
+ owner.followup(message);
224
+ return;
225
+ }
226
+ owner.inject(message);
227
+ });
228
+ ctx.tools.register(defineTool({
229
+ name: "job_output",
230
+ description: "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.",
231
+ parameters: {
232
+ job_id: {
233
+ type: "string",
234
+ required: true,
235
+ description: "Job id returned by the tool that started the background work."
236
+ },
237
+ wait: {
238
+ type: "boolean",
239
+ description: "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
240
+ },
241
+ timeout_ms: {
242
+ type: "number",
243
+ description: "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
244
+ }
245
+ },
246
+ finalizeContent: finalizeTaskContent,
247
+ output: {
248
+ schema: {
249
+ type: "object",
250
+ additionalProperties: false,
251
+ properties: {
252
+ text: {
253
+ type: "string",
254
+ required: true
255
+ },
256
+ job: {
257
+ ...PUBLIC_TASK_SCHEMA,
258
+ required: true
259
+ }
260
+ }
261
+ },
262
+ render: (_args, value) => {
263
+ const body = value.text.length > 0 ? value.text : "(no new output)";
264
+ return [{
265
+ type: "text",
266
+ text: `${body}${body.endsWith("\n") ? "" : "\n"}${statusLine(value.job)}`
267
+ }];
268
+ }
269
+ },
270
+ async execute(args, exec) {
271
+ const id = validateJobId(args.job_id);
272
+ if (args.wait === true) {
273
+ const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap);
274
+ await ctx.jobs.wait(id, timeout, exec.agent, exec.signal);
275
+ }
276
+ const read = ctx.jobs.read(id, exec.agent);
277
+ return {
278
+ text: read.text,
279
+ job: publicJob(read.snapshot)
280
+ };
281
+ },
282
+ presentCall: (args) => presentTaskCall(`Read output from background job ${args.job_id}`, "read", args.job_id)
283
+ }));
284
+ ctx.tools.register(defineTool({
285
+ name: "job_list",
286
+ description: "List your background jobs (running and finished) with their ids, kinds, and statuses.",
287
+ parameters: {},
288
+ output: {
289
+ schema: {
290
+ type: "array",
291
+ items: PUBLIC_TASK_SCHEMA
292
+ },
293
+ render: (_args, jobs) => [{
294
+ type: "text",
295
+ text: jobs.length === 0 ? "(no background jobs)" : jobs.map((t) => `${t.id} [${t.kind}] ${t.status} — ${t.label}`).join("\n")
296
+ }]
297
+ },
298
+ execute(_args, exec) {
299
+ const jobs = ctx.jobs.list(exec.agent);
300
+ return Promise.resolve(jobs.map(publicJob));
301
+ },
302
+ presentCall: () => presentTaskCall("List background jobs", "read")
303
+ }));
304
+ ctx.tools.register(defineTool({
305
+ name: "job_kill",
306
+ description: "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.",
307
+ parameters: {
308
+ job_id: {
309
+ type: "string",
310
+ required: true,
311
+ description: "Job id returned by the tool that started the background work."
312
+ },
313
+ reason: {
314
+ type: "string",
315
+ description: "Optional short reason, recorded in the log and forwarded to the job."
316
+ }
317
+ },
318
+ finalizeContent: finalizeTaskContent,
319
+ output: {
320
+ schema: {
321
+ type: "object",
322
+ additionalProperties: false,
323
+ properties: {
324
+ outcome: {
325
+ type: "string",
326
+ required: true,
327
+ enum: ["cancellation-requested", "already-finished"]
328
+ },
329
+ job: {
330
+ ...PUBLIC_TASK_SCHEMA,
331
+ required: true
332
+ }
333
+ }
334
+ },
335
+ render: (_args, value) => [{
336
+ type: "text",
337
+ text: value.outcome === "already-finished" ? `job ${value.job.id} had already finished ${statusLine(value.job)}` : `requested cancellation of job ${value.job.id}`
338
+ }]
339
+ },
340
+ execute(args, exec) {
341
+ const id = validateJobId(args.job_id);
342
+ const result = ctx.jobs.kill(id, exec.agent, args.reason);
343
+ const snapshot = publicJob(ctx.jobs.get(id, exec.agent));
344
+ return Promise.resolve({
345
+ outcome: result === "already-finished" ? "already-finished" : "cancellation-requested",
346
+ job: snapshot
347
+ });
348
+ },
349
+ presentCall: (args) => presentTaskCall(`Kill background job ${args.job_id}`, "execute", args.job_id)
350
+ }));
351
+ }
352
+ //#endregion
353
+ export { Config, apply, inject, name, statusLine };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tool-jobs`.
4
+ * @module @deepseek-ai/dsh-tool-jobs/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-tool-jobs";
7
+ /** Cordis companion plugin name. */
8
+ const name = "tool-jobs-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
13
+ * relations are owned by the capability seam it calls.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Model-facing `job_output`, `job_list`, and `job_kill` tools over
3
+ * `ctx.jobs`. Loading the plugin attaches the controller required by
4
+ * producers. It also delivers unreported completions to the owning agent:
5
+ * injected into a busy owner's next step, or opening a turn on an idle one
6
+ * under the default `wakeup` delivery, bounded per owner.
7
+ * @module @deepseek-ai/dsh-tool-jobs
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import z from '@deepseek-ai/schemastery';
11
+ import type { JobSnapshot } from '@deepseek-ai/dsh-jobs';
12
+ export declare const name = "tool-jobs";
13
+ export declare const inject: string[];
14
+ /**
15
+ * How an unreported completion reaches an owner that is already idle: `wakeup`
16
+ * opens a turn for it, `quiet` leaves it pending until something else wakes the
17
+ * owner. A busy owner is injected either way.
18
+ */
19
+ export type CompletionDelivery = 'quiet' | 'wakeup';
20
+ /** Configures bounded `job_output` waits and completion-notice delivery. */
21
+ export interface Config {
22
+ /** Wait duration applied when `job_output` sets `wait` without `timeout_ms` (default 30s). */
23
+ waitTimeoutMs?: number;
24
+ /** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
25
+ maxWaitTimeoutMs?: number;
26
+ /** Whether a completion opens a turn on an idle owner (default `wakeup`). */
27
+ completionDelivery?: CompletionDelivery;
28
+ /**
29
+ * Turns one owner may have opened by completion wakes before the next
30
+ * notice degrades to injection, reset by any user-authored input (default 3).
31
+ * Bounds the self-exciting chain where a woken turn starts the job whose
32
+ * completion wakes it again.
33
+ */
34
+ maxConsecutiveWakes?: number;
35
+ }
36
+ export declare const Config: z<Config>;
37
+ /** Task state safe for model-authored programs; ownership/bookkeeping fields are omitted. */
38
+ export interface PublicJobSnapshot {
39
+ id: string;
40
+ kind: string;
41
+ label: string;
42
+ status: JobSnapshot['status'];
43
+ detail?: string;
44
+ startedAt: number;
45
+ finishedAt?: number;
46
+ }
47
+ /**
48
+ * Render generic status with optional producer detail.
49
+ * @param snapshot - job state to render.
50
+ * @returns a bracketed status line.
51
+ */
52
+ export declare function statusLine(snapshot: Pick<JobSnapshot, 'status' | 'detail'>): string;
53
+ export declare function apply(ctx: Context, config: Config): void;
54
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@deepseek-ai/dsh-tool-jobs`.
3
+ * @module @deepseek-ai/dsh-tool-jobs/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "tool-jobs-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@deepseek-ai/dsh-tool-jobs",
3
+ "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry",
4
+ "version": "0.0.1-rc.3",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/jobs/tool-jobs"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/invariant.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "BSD-3-Clause",
34
+ "dependencies": {
35
+ "@deepseek-ai/schemastery": "^3.18.1-rc.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.3",
39
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.3",
40
+ "@deepseek-ai/dsh-output-retention": "^0.0.1-rc.3",
41
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
42
+ "@deepseek-ai/dsh-jobs": "^0.0.1-rc.3",
43
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.3",
44
+ "@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.3",
45
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
46
+ },
47
+ "devDependencies": {
48
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.3",
49
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.3",
50
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.3",
51
+ "@deepseek-ai/dsh-output-retention": "^0.0.1-rc.3",
52
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.3",
53
+ "@deepseek-ai/dsh-system-prompt": "^0.0.1-rc.3",
54
+ "@deepseek-ai/dsh-jobs": "^0.0.1-rc.3",
55
+ "@deepseek-ai/dsh-jobs-local": "^0.0.1-rc.3",
56
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.3",
57
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
58
+ }
59
+ }