fix(tasks-local): layer control surfaces and listeners by registering scope
One host registry serves every composition in the process, so its two service-wide collections answered per-owner questions process-wide. `start()` asked only whether SOME surface was attached, so an agent whose own composition loads no `tool-tasks` could start work it has no tool to collect or stop as soon as any other preset attached one — and the answer changed depending on which sessions happened to be open. `settle()` walked every registered listener, so a task settling without a waiter injected one completion notice per mounted preset into the same owner. Both collections now sit in `ScopedLayers`, the layered-registry primitive `tools` and `skills` already use: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. A surface or listener registered from an unscoped context lands in the global layer and serves every owner, which is exactly the host-plane composition's own controls, so the TUI path is unchanged without a special case. This supersedes the consumer-side filter in the previous commit. That filter produced the right notices but sat in the wrong layer: it left the `start()` gate process-wide, it could not be enforced against a producer that resolves the registry directly, and it made a Consumer carry scope knowledge that the other layered registries keep in the registry. `tool-tasks` is scope-agnostic again and the `dsh-scope` edge moves to `tasks-local`. `start()`'s refusal is now owner-relative, so its model-visible text names the agent rather than the process. The shipped `minimal` preset keeps `enableRunInBackground: false`, no longer as the safety boundary — the registry owns that now — but so an agent that could never collect a task is not offered the parameter at all. Refs #2141
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/tasks/tool-tasks/README.md
|
||||
README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8
|
||||
README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86
|
||||
README.md: 4e8872b087bac6576a3b48acbf079d3f06f3a131
|
||||
README.zh.md: 3beeb1b70c3a757f5935b0621c9648e0a02c7aa5
|
||||
|
||||
@@ -20,7 +20,7 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill`
|
||||
|
||||
An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. 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. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice.
|
||||
|
||||
One host registry may carry several mounts of this plugin — one per agent preset — and the registry broadcasts each settlement to every mount. A scoped mount delivers only to owners composed under its own scope, so an agent reads exactly one notice per completion however many presets are mounted; an unscoped mount is the host-plane instance and delivers to every owner.
|
||||
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 control surface serves: an agent whose composition loads no `tool-tasks` cannot start background work at all.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。
|
||||
|
||||
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份——而注册表会把每次结算广播给全部挂载。带 scope 的挂载只向在其自身 scope 下组合出的所有者交付,因此无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知;不带 scope 的挂载是宿主平面实例,向每个所有者交付。
|
||||
一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制表层服务哪些 agent:组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -43,7 +42,6 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
@@ -227,21 +226,16 @@ export function apply(ctx: Context, config: Config): void {
|
||||
text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.',
|
||||
})
|
||||
|
||||
// Use the exact lifecycle owner; reusable ids could resolve to a replacement.
|
||||
// Delivery targets the exact lifecycle owner. The notice waits in its
|
||||
// next-step inbox until another step claims it; disposal before that
|
||||
// boundary discards it with the owner.
|
||||
//
|
||||
// One host registry can carry SEVERAL mounts of this plugin — one per agent
|
||||
// preset — and `settle()` broadcasts a single snapshot to every registered
|
||||
// listener with no scope filter of its own. Each mount must therefore claim
|
||||
// only the owners composed under it, or every mounted preset injects the
|
||||
// same completion into the same agent and the model reads N copies of one
|
||||
// notice. An unscoped mount is the host-plane instance that serves every
|
||||
// agent, so it claims all of them.
|
||||
const mountScope = scopeOf(ctx)
|
||||
// The registry routes each settlement to the listeners its owner's scope
|
||||
// chain reaches, so a mount under one preset never sees another preset's
|
||||
// agents; this listener owns delivery, not the choice of whom to deliver to.
|
||||
ctx.tasks.onTaskDone((snapshot, owner) => {
|
||||
if (snapshot.reported || owner === undefined) return
|
||||
if (mountScope !== undefined && !scopeChainOf(scopeOf(owner.ctx)).includes(mountScope)) return
|
||||
owner.inject(createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('tool-tasks setup', () => {
|
||||
const { ctx, toolsFiber } = await setup()
|
||||
expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
|
||||
await toolsFiber.dispose()
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
|
||||
expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent')
|
||||
})
|
||||
|
||||
it('rejects a config whose default wait exceeds the cap', async () => {
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user