diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index 0fd4ab1228..36af85437a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -22,7 +22,7 @@ {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"lead [running] — root agent\nlead/legacy:{{sessionId}} [inactive] parent=lead depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [running] — lead: root agent\n{{sessionId}} [inactive] parent={{sessionId}} depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index bc5558745f..d44ba911cd 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"sandbox/mode","data":{"mode":"workspace-write","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your name is \"lead/agent-1\". Your parent agent name is \"lead\"."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"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":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your name is \"lead/agent-1\". Your parent agent name is \"lead\"."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYour name is \"lead/agent-1\". Your parent agent name is \"lead\". Use send_message({ name: \"lead\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:communication","text":"Your name is \"lead/agent-1\". Your parent agent name is \"lead\". Use send_message({ name: \"lead\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"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: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\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`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYour parent agent id is \"{{sessionId}}\". Use send_message({ name: \"{{sessionId}}\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"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`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:communication","text":"Your parent agent id is \"{{sessionId}}\". Use send_message({ name: \"{{sessionId}}\", message: \"\" }) when a finding changes what the parent should do next; sending a message does not end your turn."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"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":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index 391c306447..61bc45735b 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -12,9 +12,9 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"start-child","name":"subagent","args":["{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent-name/bound","seq":17,"time":0,"data":{"version":1,"childId":"{{sessionId}}","name":"agent-1"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":18,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent lead/agent-1"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent-name/bound","seq":18,"time":0,"data":{"version":1,"childId":"{{sessionId}}","name":"lead/agent-1"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}\nname: lead/agent-1"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":21,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index b17aeb1709..f341a5d0b0 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -72,7 +72,7 @@ function researcher(messages) { if (!names.includes('list_agents')) return toolChunks([{ name: 'list_agents', args: {} }]) const sibling = messages.filter(message => message.role === 'tool') .flatMap(message => message.content.filter(block => block.type === 'text').flatMap(block => block.text.split('\n'))) - .find(line => line.endsWith('— Own deterministic implementation.'))?.split(' ')[0] + .find(line => line.endsWith('Own deterministic implementation.'))?.split(' ')[0] if (sibling === undefined) throw new Error('list_agents did not expose the implementation sibling') if (!names.includes('team_task_create')) { return toolChunks([{ name: 'team_task_create', args: { @@ -179,7 +179,7 @@ function lead(messages) { } if (last.includes('list_agents')) { const inactive = result.split('\n') - .filter(row => row.includes('— Own deterministic ') && row.includes('[inactive]')).length + .filter(row => row.includes('Own deterministic ') && row.includes('[inactive]')).length if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'list_agents' : 'wait_agent', args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) @@ -202,8 +202,8 @@ class TeamFixtureAdapter extends LlmAdapter { } let chunks if (workflowChild) chunks = textChunks('Fresh workflow child complete.') - else if (task?.includes('RESEARCHER_MARK:')) chunks = researcher(options.messages) - else if (task?.includes('IMPLEMENTER_MARK:')) chunks = implementer(options.messages) + else if (task?.startsWith('RESEARCHER_MARK:')) chunks = researcher(options.messages) + else if (task?.startsWith('IMPLEMENTER_MARK:')) chunks = implementer(options.messages) else if (role?.includes('You are the Team Lead.')) chunks = lead(options.messages) else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 39ecb1276d..9142f7ebbd 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -1179,8 +1179,8 @@ describe('headless stream-json snapshots', () => { && ((row.data as JsonObject).source as JsonObject).kind === 'user') if (initial === undefined) throw new Error('Teammate Session has no initial task') const content = (initial.data as JsonObject).content as JsonObject[] - // Named delegations include the task, return guidance, and identity. - expect(content).toHaveLength(3) + // The task block is followed by the standard return guidance naming the Lead. + expect(content).toHaveLength(2) const context = parseJsonl(log.content).find(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'runtime-context') const source = (context?.data as JsonObject)?.source as JsonObject diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index d3fa4ac35a..3aff474038 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -132,7 +132,7 @@ describe('parent-owned cold child catalog snapshot', () => { if (parent === undefined) throw new Error('missing persisted parent log') // Discovery reads child catalogs without requiring activation descriptors. - expect(parent).toContain(`lead/legacy:${childId} [inactive] parent=lead depth=1 — descriptorless child`) + expect(parent).toContain(`${childId} [inactive] parent=${parentId} depth=1 — descriptorless child`) expect(parent).not.toContain('[diagnostic: corrupt]') const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } diff --git a/apps/web/tests/official-subagent-bundle.e2e.ts b/apps/web/tests/official-subagent-bundle.e2e.ts index 92a1858b6a..e5f5895100 100644 --- a/apps/web/tests/official-subagent-bundle.e2e.ts +++ b/apps/web/tests/official-subagent-bundle.e2e.ts @@ -156,7 +156,9 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl if (notice?.data.source.kind !== 'subagent-settled') throw new Error('Native completion was not delivered to the parent') expect(notice.data.source.senderSessionId).toBe(catalog[0]?.childId) expect(notice.data.content).toContainEqual({ type: 'text', text: RESPONSE }) - expect(dispatches[0]?.content).toEqual([{ type: 'text', text: 'started subagent lead/native-worker' }]) + expect(dispatches[0]?.content).toEqual([{ + type: 'text', text: `started subagent ${notice.data.source.senderSessionId}\nname: lead/native-worker`, + }]) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) expect(server.requests).toHaveLength(1) expect(server.requests[0]?.body).toMatchObject({ model: 'fixture-model' }) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index bbcd58b9d5..6ebfb8e14f 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 4e6039d7f4fd20ed - zh: 69dac723f536eebe + en: eb93c7501376985c + zh: 178bfc8e12149b47 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 00358a7282..2c515ce9d3 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -727,7 +727,7 @@ flowchart LR | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`subagent-names`](../packages/subagent/subagent-names), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. | -| `ctx.subagentNames` | `core` | [`subagent-names`](../packages/subagent/subagent-names) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | - | Owns sibling name reservations, durable bindings, and catalog-backed path lookup. Execution and authorization remain with the subagent service. | +| `ctx.subagentNames` | `core` | [`subagent-names`](../packages/subagent/subagent-names) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | - | Owns name reservations and durable name-to-id bindings in the root Session. Execution and authorization remain with the subagent service. | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the shared task DAG and wait signals for a subagent tree; tool-agent-team contributes task controls. | | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 3fd048cf76..9700224fde 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -729,7 +729,7 @@ flowchart LR | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`subagent-names`](../packages/subagent/subagent-names), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 提供方准备本地子级或执行外部任务;一个 activation 管理器持有两类生命周期。委派工具选择后端,控制工具投递本地后续消息,工作流等待 activation 结果。 | -| `ctx.subagentNames` | `core` | [`subagent-names`](../packages/subagent/subagent-names) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | - | 持有同级名字预留、持久绑定和基于 catalog 的路径查询。执行与授权仍由 subagent 服务负责。 | +| `ctx.subagentNames` | `core` | [`subagent-names`](../packages/subagent/subagent-names) | - | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | - | 持有名字预留及根 Session 中持久的名字到 ID 绑定。执行与授权仍由 subagent 服务负责。 | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责子代理树共享的任务 DAG 和等待信号;tool-agent-team 提供任务控制工具。 | | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | 加载 defineMod 插件添加的模组,从 harness 扩展点触发其钩子链,并通过其 Remote 绘制提示框上方的横幅。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index db5ffcdd05..91d8ac535f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1327,7 +1327,7 @@ export type Config = Record ## `@deepseek-ai/dsh-experimental-tool-subagent-oar` - `inject`: `tools` · `subagents` · `subagentNames` -- `source`: [`packages/experimental/tool-subagent-oar/src/index.ts:13`](../packages/experimental/tool-subagent-oar/src/index.ts) +- `source`: [`packages/experimental/tool-subagent-oar/src/index.ts:14`](../packages/experimental/tool-subagent-oar/src/index.ts) ```ts config-catalog /** Deployment-owned runtime choices exposed by the external delegation tool. */ @@ -2105,7 +2105,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable ## `@deepseek-ai/dsh-experimental-tool-subagent-oar` - `inject`: `tools` · `subagents` · `subagentNames` -- `source`: [`packages/experimental/tool-subagent-oar/src/index.ts:13`](../packages/experimental/tool-subagent-oar/src/index.ts) +- `source`: [`packages/experimental/tool-subagent-oar/src/index.ts:14`](../packages/experimental/tool-subagent-oar/src/index.ts) ```ts config-catalog /** Deployment-owned runtime choices exposed by the external delegation tool. */ @@ -2107,7 +2107,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable = { ```ts persistence-catalog /** - * A name candidate, usable only while the parent catalog contains its child. - * @param data - versioned child identity and sibling name. + * A root-owned alias for an accepted subagent identity. + * @param data - versioned child identity and complete name. */ 'subagent-name/bound': SubagentNameBinding & { readonly version: 1 } ``` diff --git a/docs/persistence-changes/2026-10-25-subagent-tool-names.i18n.yaml b/docs/persistence-changes/2026-10-25-subagent-tool-names.i18n.yaml index 0417c41c38..f0c9dcea5e 100644 --- a/docs/persistence-changes/2026-10-25-subagent-tool-names.i18n.yaml +++ b/docs/persistence-changes/2026-10-25-subagent-tool-names.i18n.yaml @@ -9,8 +9,8 @@ en: 74dd331220011a1b zh: 008acdd3c41a03f4 /2026-10-25-subagent-tool-names/summary: - en: f6ea2d6660a3e32b - zh: a2d6ad218f0dafb2 + en: bb91739791e437e2 + zh: a75903b7a3d1c437 /2026-10-25-subagent-tool-names/table-of-contents: en: 6cf314a069ceb451 zh: 9adff678e9ecab3b @@ -21,8 +21,8 @@ en: e50cc33503dc233f zh: 3e08289200786f8b /2026-10-25-subagent-tool-names/verification: - en: 926892abb1555020 - zh: f4adaada56e3232e + en: 5e801f6794f1bdee + zh: c3313150883bf75e /2026-10-25-subagent-tool-names/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-25-subagent-tool-names.md b/docs/persistence-changes/2026-10-25-subagent-tool-names.md index e9e82dbc45..5535477b9b 100644 --- a/docs/persistence-changes/2026-10-25-subagent-tool-names.md +++ b/docs/persistence-changes/2026-10-25-subagent-tool-names.md @@ -9,7 +9,7 @@ English | [中文](2026-10-25-subagent-tool-names.zh.md) ## Summary -Adds the parent-owned subagent-name/bound event for optional named subagent creation. +Adds the root-Session-owned subagent-name/bound event for persistent name-to-id bindings. ## Table of Contents @@ -40,7 +40,7 @@ Existing V4 Sessions remain readable and unnamed catalog children retain their S ## Verification -The subagent-names package tests cover candidate replay, inherited-fork exclusion, checkpoint recovery, duplicate binding rejection, cold parent lookup, and startup failure. The focused storage-contract.spec.ts and V4 codec.spec.ts run passed 37 tests, including refusal of unknown required events before adoption. +The subagent-names package tests cover binding replay, inherited-fork exclusion, checkpoint recovery, and conflicting binding rejection. The storage-contract.spec.ts and V4 codec.spec.ts tests cover refusal of unknown required events before adoption. ## Dev Note diff --git a/docs/persistence-changes/2026-10-25-subagent-tool-names.zh.md b/docs/persistence-changes/2026-10-25-subagent-tool-names.zh.md index 75f31a50b3..70766711ec 100644 --- a/docs/persistence-changes/2026-10-25-subagent-tool-names.zh.md +++ b/docs/persistence-changes/2026-10-25-subagent-tool-names.zh.md @@ -9,7 +9,7 @@ kind: persistence-change ## 概述 -为可选的子智能体命名创建增加由父会话持有的 subagent-name/bound 事件。 +增加由根 Session 持有的 subagent-name/bound 事件,持久保存名字到 ID 的绑定。 ## 目录 @@ -40,7 +40,7 @@ changes: ## 验证 -subagent-names 包测试覆盖候选记录回放、排除 fork 继承事件、checkpoint 恢复、拒绝重复绑定、冷父节点查询和启动失败。针对 storage-contract.spec.ts 与 V4 codec.spec.ts 的测试共 37 项通过,其中包含在接管前拒绝未知必读事件。 +subagent-names 包测试覆盖绑定回放、排除 fork 继承事件、checkpoint 恢复及拒绝冲突绑定。storage-contract.spec.ts 与 V4 codec.spec.ts 测试覆盖在接管前拒绝未知必读事件。 ## 开发备注 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e46ec16449..d217eaded5 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -18,8 +18,8 @@ en: e02e1703b183594c zh: 130d475a4304f453 /subagents/optional-tool-names: - en: 78f0d036ab8b1d4a - zh: 1974a1451c350de3 + en: 44bac31957293e47 + zh: 1e6c9c38e07bd61a /subagents/results-and-backend-handles: en: 69ea5bed7d9fb64a zh: b187a822b813e80a diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 16e011844e..b90c0c338a 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -227,7 +227,7 @@ The parent-owned `subagentCatalog` projection lists all local children and paren ## Optional tool names -The [naming plugin](../../packages/subagent/subagent-names/README.md) owns name allocation and path lookup for model-facing tools. It combines its own parent-owned bindings with the existing catalog; execution, authorization, provider requests and runtime notifications continue to use Session ids. Direct service callers do not acquire names automatically. +The [naming plugin](../../packages/subagent/subagent-names/README.md) owns name allocation and path lookup for model-facing tools. It stores name-to-id bindings in the root Session; execution, authorization, provider requests and runtime notifications continue to use Session ids. Direct service callers do not acquire names automatically. ## Results and backend handles @@ -413,44 +413,41 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../. ### `ctx.subagentNames` — `SubagentNames` -Catalog-backed names that leave subagent execution and authorization with their existing owner. +Root-Session aliases shared by named creation, discovery, and control tools. ```ts cordis-catalog /** - * Start a named child, reserving its sibling name until startup settles. - * Parent delivery retains catalog membership. Local names precede catalog publication; - * external names follow accepted startup. - * @param spec - ordinary activation options and an optional lower-case sibling name. - * @returns the existing activation receipt plus its full name. - * @throws SubagentNameError for invalid or duplicate names. An external binding failure - * includes childId and awaits disposal without undoing completed work. + * Reserve a complete name without starting an agent or writing a Session event. + * @param parent - exact live caller whose name prefixes the child name. + * @param requested - optional sibling segment; omission selects the first free agent-N. + * @returns a reservation the caller must dispose after startup and binding. */ -async startActivation(spec: NamedSubagentActivationSpec): Promise +reserve(parent: Agent, requested?: string): SubagentNameReservation /** - * Read the full name of an exact live Agent without restoring its ancestors. - * @param agent - live root or managed child whose name is requested. - * @param signal - cancellation during parent catalog reads. - * @returns lead, named path segments, or legacy id segments for unnamed children. + * Record an accepted child's full name in the caller's root Session. + * Binding failure does not cancel or dispose the child; callers report its id. + * @param parent - exact live caller whose root owns the alias. + * @param childId - identity returned by the subagent service. + * @param name - complete name returned by reserve. */ -async nameOf(agent: Agent, signal?: AbortSignal): Promise +bind(parent: Agent, childId: SessionId, name: string): void /** - * Translate a canonical path or a tree member's exact id without granting authority. - * @param caller - exact live Agent selecting the subagent tree. - * @param name - lead, a lead-prefixed path, or an existing member's exact Session id. - * @param signal - cancellation during catalog reads. - * @returns the identity for the subagent operation to authorize. + * Read a bound name from the live root's projection without loading ancestors. + * @param caller - exact live caller selecting the tree. + * @param targetId - requested identity; omission selects the caller. + * @returns lead for the root, a bound full name, or the unchanged id. */ -async resolveName(caller: Agent, name: string, signal?: AbortSignal): Promise +nameOf(caller: Agent, targetId: SessionId = caller.id): string /** - * Attach relative names to the existing catalog traversal and its branch diagnostics. - * @param rootId - Session whose catalog starts discovery. - * @param signal - cancellation while reading descendant and name projections. - * @returns existing descendant rows with paths relative to rootId. + * Resolve an alias or pass through an id for the subagent service to authorize. + * @param caller - exact live caller selecting the root mapping. + * @param name - lead, a bound full name, or a raw Session id. + * @returns the target identity; an unknown lead/... name throws. */ -async listDescendants(rootId: SessionId, signal?: AbortSignal): Promise<(SubagentDescendantListEntry & { readonly path: string })[]> +resolveName(caller: Agent, name: string): SessionId ``` Types: [Agent](core.md) · [SessionId](core.md) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 03d00d0910..ac8e0810f3 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -227,7 +227,7 @@ type SubagentInterruptAuthority = ## 可选工具名字 -[命名插件](../../packages/subagent/subagent-names/README.zh.md)负责模型工具的名字分配和路径查询。它将自己持有的父级绑定与现有 catalog 结合;执行、授权、provider 请求和运行时通知继续使用 Session id。直接调用服务不会自动分配名字。 +[命名插件](../../packages/subagent/subagent-names/README.zh.md)负责模型工具的名字分配和路径查询。它在根 Session 中保存名字到 ID 的绑定;执行、授权、provider 请求和运行时通知继续使用 Session id。直接调用服务不会自动分配名字。 ## 结果与后端句柄 @@ -415,44 +415,41 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../. ### `ctx.subagentNames` — `SubagentNames` -Catalog-backed names that leave subagent execution and authorization with their existing owner. +Root-Session aliases shared by named creation, discovery, and control tools. ```ts cordis-catalog /** - * Start a named child, reserving its sibling name until startup settles. - * Parent delivery retains catalog membership. Local names precede catalog publication; - * external names follow accepted startup. - * @param spec - ordinary activation options and an optional lower-case sibling name. - * @returns the existing activation receipt plus its full name. - * @throws SubagentNameError for invalid or duplicate names. An external binding failure - * includes childId and awaits disposal without undoing completed work. + * Reserve a complete name without starting an agent or writing a Session event. + * @param parent - exact live caller whose name prefixes the child name. + * @param requested - optional sibling segment; omission selects the first free agent-N. + * @returns a reservation the caller must dispose after startup and binding. */ -async startActivation(spec: NamedSubagentActivationSpec): Promise +reserve(parent: Agent, requested?: string): SubagentNameReservation /** - * Read the full name of an exact live Agent without restoring its ancestors. - * @param agent - live root or managed child whose name is requested. - * @param signal - cancellation during parent catalog reads. - * @returns lead, named path segments, or legacy id segments for unnamed children. + * Record an accepted child's full name in the caller's root Session. + * Binding failure does not cancel or dispose the child; callers report its id. + * @param parent - exact live caller whose root owns the alias. + * @param childId - identity returned by the subagent service. + * @param name - complete name returned by reserve. */ -async nameOf(agent: Agent, signal?: AbortSignal): Promise +bind(parent: Agent, childId: SessionId, name: string): void /** - * Translate a canonical path or a tree member's exact id without granting authority. - * @param caller - exact live Agent selecting the subagent tree. - * @param name - lead, a lead-prefixed path, or an existing member's exact Session id. - * @param signal - cancellation during catalog reads. - * @returns the identity for the subagent operation to authorize. + * Read a bound name from the live root's projection without loading ancestors. + * @param caller - exact live caller selecting the tree. + * @param targetId - requested identity; omission selects the caller. + * @returns lead for the root, a bound full name, or the unchanged id. */ -async resolveName(caller: Agent, name: string, signal?: AbortSignal): Promise +nameOf(caller: Agent, targetId: SessionId = caller.id): string /** - * Attach relative names to the existing catalog traversal and its branch diagnostics. - * @param rootId - Session whose catalog starts discovery. - * @param signal - cancellation while reading descendant and name projections. - * @returns existing descendant rows with paths relative to rootId. + * Resolve an alias or pass through an id for the subagent service to authorize. + * @param caller - exact live caller selecting the root mapping. + * @param name - lead, a bound full name, or a raw Session id. + * @returns the target identity; an unknown lead/... name throws. */ -async listDescendants(rootId: SessionId, signal?: AbortSignal): Promise<(SubagentDescendantListEntry & { readonly path: string })[]> +resolveName(caller: Agent, name: string): SessionId ``` Types: [Agent](core.zh.md) · [SessionId](core.zh.md) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 5773040762..67c5ae13f9 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: 032f2dde75d30202 - zh: 2ea1f54a96e15461 + en: 22a4fdb27f864fd3 + zh: ab6d409bf9191495 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -237,8 +237,8 @@ en: 8daafae6faf7898e zh: 8daafae6faf7898e /tool-schema-catalog/deepseek-ai-dsh-experimental-tool-subagent-oar/subagent-oar: - en: a29a86479752a3ef - zh: 8582b31fc5e99c17 + en: c39b672aa8258fba + zh: 111aa3637f661b2f /tool-schema-catalog/deepseek-ai-dsh-tool-subagent: en: 841abe0dd5913ea7 zh: 841abe0dd5913ea7 @@ -246,8 +246,8 @@ en: 207228068bc61885 zh: 36e9344ee1af8869 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent: - en: 510d7dd3afaa98b9 - zh: 01e3650be0e1acd9 + en: d331570a9e0219ac + zh: 1f39bfe30de418f0 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control: en: ab82e130c3413f04 zh: ab82e130c3413f04 @@ -255,14 +255,14 @@ en: a622b15cc2598d51 zh: 48e1b05956d63e26 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents: - en: 27354bec67e9f3c1 - zh: 282ef6ae40681e2b + en: 7e5f44383f14d021 + zh: 8037d7b62ddb33b2 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message: en: aaccc4f194398018 zh: d918c1346a87a4b1 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/wait-agent: - en: d150b517e88f87f0 - zh: ba83919e0e13543a + en: 560bfc25f9a73740 + zh: 404626c11e5a936b /tool-schema-catalog/deepseek-ai-dsh-tool-jobs: en: 9be673b1604d0114 zh: 9be673b1604d0114 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c3fc11c6c8..a636b4f330 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -40,8 +40,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`, `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `ctx.agents`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents`, `ctx.subagentNames` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | The subagent_oar tool selects among configured aliases for external providers. This catalog shows Codex and Claude Code aliases; each deployment chooses its providers. Native model and permission settings belong to the selected provider configuration. | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.sessionProjections`, `optional ctx.subagentNames for names instead of Session ids`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. This catalog includes the optional naming plugin: delegation accepts a sibling name and returns its full path promptly. Without that plugin it accepts no name and returns a Session id. Local children accept follow-ups, while external backends report their single execution automatically. | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message`, `wait_agent` | `ctx.tools`, `ctx.subagents`, `ctx.agents`, `ctx.systemPrompt`, `optional ctx.subagentNames for names instead of Session ids` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. This catalog includes optional naming: controls accept a name path or Session id through `name`, and discovery returns paths. Without that plugin, controls retain `agent_id` and discovery returns Session ids. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.sessionProjections`, `optional ctx.subagentNames for names alongside Session ids`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. This catalog includes the optional naming plugin: delegation accepts a sibling name and returns its full path alongside the child Session id. Without that plugin it accepts no name and returns a Session id. Local children accept follow-ups, while external backends report their single execution automatically. | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message`, `wait_agent` | `ctx.tools`, `ctx.subagents`, `ctx.agents`, `ctx.systemPrompt`, `optional ctx.subagentNames for names alongside Session ids` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. This catalog includes optional naming: controls accept a name path or Session id through `name`, and discovery adds names while retaining Session ids. Without that plugin, controls retain `agent_id` and discovery returns Session ids. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `ctx.subagentNames`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | Four task-board tools share the subagent tree’s root Session. The optional Agent Teams profile adds task-board guidance and required Team identity derived from the subagent root, alongside standard subagent delegation and control tools. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | @@ -2113,7 +2113,7 @@ The five read-only tools hide provider cursors and authorize every result from t ### `subagent_oar` -Delegate a self-contained task to a configured external agent. Choose its runtime for each call. The child works in its own context and does not see this conversation. This tool returns a full agent path; a later completion notice includes its final answer. The child does not accept follow-up messages. Start independent tasks together and continue useful work while they run. When only waiting for their results remains, end your turn instead of polling. +Delegate a self-contained task to a configured external agent. Choose its runtime for each call. The child works in its own context and does not see this conversation. This tool returns the child id and full agent path; a later completion notice includes its final answer. The child does not accept follow-up messages. Start independent tasks together and continue useful work while they run. When only waiting for their results remains, end your turn instead of polling. ```json { @@ -2184,7 +2184,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its name. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes. ```json { @@ -2216,7 +2216,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. This catalog includes the optional naming plugin: delegation accepts a sibling name and returns its full path promptly. Without that plugin it accepts no name and returns a Session id. Local children accept follow-ups, while external backends report their single execution automatically. +The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. This catalog includes the optional naming plugin: delegation accepts a sibling name and returns its full path alongside the child Session id. Without that plugin it accepts no name and returns a Session id. Local children accept follow-ups, while external backends report their single execution automatically. @@ -2245,7 +2245,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry full agent paths, descriptions, and status; descendant rows carry parent paths and depth from the root. You can message any other agent in the subagent tree listed as running or inactive. running means it is working; inactive means it is not currently working. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation. +List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry ids, descriptions, and status; descendant rows carry parent ids and depth from the root. Rows also include available names alongside Session ids. You can message any other agent in the subagent tree listed as running or inactive. running means it is working; inactive means it is not currently working. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation. ```json { @@ -2300,7 +2300,7 @@ Wait for input at your next step, including agent messages, completion notificat Source: [`packages/subagent/tool-subagent-control/src/wait-agent.ts`](../packages/subagent/tool-subagent-control/src/wait-agent.ts) -The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. This catalog includes optional naming: controls accept a name path or Session id through `name`, and discovery returns paths. Without that plugin, controls retain `agent_id` and discovery returns Session ids. +The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. This catalog includes optional naming: controls accept a name path or Session id through `name`, and discovery adds names while retaining Session ids. Without that plugin, controls retain `agent_id` and discovery returns Session ids. @@ -2533,7 +2533,7 @@ Compare-and-set a shared task action using the latest revision from team_task_ge }, "owner": { "type": "string", - "description": "Full agent path of the root or any continuable descendant for root-only reassign; omit to unassign." + "description": "Agent name or Session id of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index c6e16c649d..e6fef4e7ba 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -44,8 +44,8 @@ | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`、`ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents`, `ctx.subagentNames` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | subagent_oar 工具在为外部提供方配置的别名之间选择。本目录展示 Codex 和 Claude Code 别名;每个部署自行选择提供方。原生模型和权限设置由所选提供方的配置决定。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`ctx.sessionProjections`、`用于名称替代 Session id 的可选 ctx.subagentNames`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。本目录加载可选的命名插件:委派接受同级名称并立即返回完整路径。没有该插件时,不接受名称,返回 Session id。本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message`、`wait_agent` | `ctx.tools`、`ctx.subagents`、`ctx.agents`、`ctx.systemPrompt`、`用于名称替代 Session id 的可选 ctx.subagentNames` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。本目录加载可选命名功能:控制工具通过 `name` 接受名称路径或 Session id,发现结果返回路径。没有该插件时,控制工具保留 `agent_id`,发现结果返回 Session id。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`ctx.sessionProjections`、`用于附加名称的可选 ctx.subagentNames`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。本目录加载可选的命名插件:委派接受同级名称,并在子级 Session id 之外返回完整路径。没有该插件时,不接受名称,返回 Session id。本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message`、`wait_agent` | `ctx.tools`、`ctx.subagents`、`ctx.agents`、`ctx.systemPrompt`、`用于附加名称的可选 ctx.subagentNames` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。本目录加载可选命名功能:控制工具通过 `name` 接受名称路径或 Session id,发现结果保留 Session id,并附加名称。没有该插件时,控制工具保留 `agent_id`,发现结果返回 Session id。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | | `@deepseek-ai/dsh-experimental-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `ctx.subagentNames`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | 四个任务板工具共享 subagent 树的根 Session。可选 Agent Teams profile 在标准子代理委派和控制工具之外增加任务板指导,以及从 subagent 根派生的必需 Team 身份。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | @@ -2119,7 +2119,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent_oar` -将上下文完整的任务委派给已配置的外部 agent。每次调用选择一个运行时。subagent 在独立上下文中工作,无法看到当前对话。工具返回完整 Agent 路径,后续完成通知包含其最终回答。subagent 不接受后续消息。并发启动相互独立的任务,并在其运行期间继续其他有用工作。只剩等待结果时结束当前轮次,不要轮询。 +将上下文完整的任务委派给已配置的外部 agent。每次调用选择一个运行时。subagent 在独立上下文中工作,无法看到当前对话。工具返回子级 id 和完整 Agent 路径,后续完成通知包含其最终回答。subagent 不接受后续消息。并发启动相互独立的任务,并在其运行期间继续其他有用工作。只剩等待结果时结束当前轮次,不要轮询。 ```json { @@ -2190,7 +2190,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其名称。如果它结束时你仍在线,运行时会自动递送其完成通知。通知包含最终回答,除非子任务在结束前刚给你发过消息。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。如果它结束时你仍在线,运行时会自动递送其完成通知。通知包含最终回答,除非子任务在结束前刚给你发过消息。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 ```json { @@ -2222,7 +2222,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。本目录加载可选的命名插件:委派接受同级名称并立即返回完整路径。没有该插件时,不接受名称,返回 Session id。本地子代理接受后续消息,外部后端自动汇报单次执行结果。 +加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。本目录加载可选的命名插件:委派接受同级名称,并在子级 Session id 之外返回完整路径。没有该插件时,不接受名称,返回 Session id。本地子代理接受后续消息,外部后端自动汇报单次执行结果。 @@ -2251,7 +2251,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `list_agents` -列出整棵 subagent 树:根代理和所有可达的可继续后代,包括调用方自身。行包含完整 Agent 路径、描述和状态;后代行包含父级路径和相对根的深度。可以向树中列为 running 或 inactive 的任意其他代理发送消息。running 表示正在工作;inactive 表示当前没有工作。自身仍在线时,直属子代理结束会收到通知,无需反复查询其状态。使用 send_message 继续对话。 +列出整棵 subagent 树:根代理和所有可达的可继续后代,包括调用方自身。行包含 id、描述和状态;后代行包含父级 id 和相对根的深度。行也会在 Session id 之外提供可用名称。可以向树中列为 running 或 inactive 的任意其他代理发送消息。running 表示正在工作;inactive 表示当前没有工作。自身仍在线时,直属子代理结束会收到通知,无需反复查询其状态。使用 send_message 继续对话。 ```json { @@ -2306,7 +2306,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 来源:[`packages/subagent/tool-subagent-control/src/wait-agent.ts`](../packages/subagent/tool-subagent-control/src/wait-agent.ts) -共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。本目录加载可选命名功能:控制工具通过 `name` 接受名称路径或 Session id,发现结果返回路径。没有该插件时,控制工具保留 `agent_id`,发现结果返回 Session id。 +共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。本目录加载可选命名功能:控制工具通过 `name` 接受名称路径或 Session id,发现结果保留 Session id,并附加名称。没有该插件时,控制工具保留 `agent_id`,发现结果返回 Session id。 @@ -2539,7 +2539,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "owner": { "type": "string", - "description": "Full agent path of the root or any continuable descendant for root-only reassign; omit to unassign." + "description": "Agent name or Session id of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.i18n.yaml index 3366ed2f74..c158c336ba 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 73ec1920e6ebd4bc zh: 1b7688c81f1dfc9a /subagent-tools-use-optional-names/change: - en: acaedcb0dfb7db00 - zh: a105b56d9bac7929 + en: 7220e2906196bdf2 + zh: f7a1dc36354d0dd3 /subagent-tools-use-optional-names/migration: - en: 470fd95ac5394bd2 - zh: 12fce1ddb43e9af7 + en: 4505282768738c8e + zh: 71b829888a5eacd2 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.md index 63bc670003..d45243f6d2 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.md @@ -9,13 +9,13 @@ English | [中文](guide.zh.md) ## Change -Base-backed profiles mount `@deepseek-ai/dsh-subagent-names`. With this plugin, `subagent` and `subagent_fork` accept an optional sibling `name` and return a full path such as `lead/reviewer`. Omission allocates the first unused `agent-N`. `send_message` and `interrupt_agent` use the `name` argument, which accepts a discovered path or an existing member's Session id. `list_agents` returns names and parent paths. Experimental Team tools expose `ownerName`; the OAR tool also returns a name. +Base-backed profiles mount `@deepseek-ai/dsh-subagent-names`. With this plugin, `subagent` and `subagent_fork` accept an optional sibling `name` and return `subagentId` plus a full `name` such as `lead/reviewer`. Omission allocates the first unused `agent-N`. `send_message` and `interrupt_agent` use the `name` argument, which accepts a discovered path or an existing member's Session id. `list_agents` adds names and parent paths to its ID fields. Experimental Team tools expose `ownerName`; the OAR tool also returns a name. -The subagent service, provider requests, Session catalog and SDK handles retain their existing ID interfaces. Runtime messages and completion notices still identify agents by Session id. The naming plugin records its own binding events. Without it, the ordinary delegation and control tools retain their ID interfaces; experimental Team and OAR tools require it. +The subagent service, provider requests, Session catalog and SDK handles retain their existing ID interfaces. Runtime messages and completion notices still identify agents by Session id. The naming plugin records name-to-id bindings in the root Session after startup succeeds. A child can begin its first step before its name is bound. Without it, the ordinary delegation and control tools retain their ID interfaces; experimental Team and OAR tools require it. ## Migration 1. Update scripted model tool calls from `send_message({ agent_id: id, ... })` to `send_message({ name: id, ... })`, and from `interrupt_agent({ agent_id: id })` to `interrupt_agent({ name: id })`. Use the returned `name` for subsequent calls when available. -2. Read `name` from named creation and discovery results, and `ownerName` from experimental Team task results. Keep existing host `startActivation`, `sendMessage`, `interrupt`, and `ownerId` calls unchanged. -3. In custom compositions using named tools, mount `@deepseek-ai/dsh-subagent-names` alongside the subagent service and Session query/projection services. The shipped base bundle supplies this plugin. -4. Create a child with `name: "reviewer"`, confirm the result is `lead/reviewer`, and confirm `list_agents` and a follow-up message resolve that path. Do not reuse a sibling name whose child remains in the parent catalog. +2. Read the optional `name` alongside the existing ID from creation and discovery results, and `ownerName` from experimental Team task results. Keep existing host `startActivation`, `sendMessage`, `interrupt`, and `ownerId` calls unchanged. +3. In custom compositions using named tools, mount `@deepseek-ai/dsh-subagent-names` alongside the subagent service and Session projection service. The shipped base bundle supplies this plugin. +4. Create a child with `name: "reviewer"`, confirm the result is `lead/reviewer`, and confirm `list_agents` and a follow-up message resolve that path. Names remain reserved for the lifetime of the root Session. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.zh.md index 6a50d9332a..c6c2c770f1 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-tool-names/guide.zh.md @@ -9,13 +9,13 @@ description: "基于 base 的配置通过模型工具中的名字寻址子代理 ## 变更 -基于 base 的配置挂载 `@deepseek-ai/dsh-subagent-names`。启用该插件后,`subagent` 和 `subagent_fork` 接受可选的同级 `name`,返回 `lead/reviewer` 这样的完整路径。省略时分配首个未使用的 `agent-N`。`send_message` 和 `interrupt_agent` 使用 `name` 参数,接受发现到的路径或现有成员的 Session id。`list_agents` 返回名字和父路径。实验性 Team 工具提供 `ownerName`;OAR 工具也返回名字。 +基于 base 的配置挂载 `@deepseek-ai/dsh-subagent-names`。启用该插件后,`subagent` 和 `subagent_fork` 接受可选的同级 `name`,返回 `subagentId`,以及 `lead/reviewer` 这样的完整 `name`。省略时分配首个未使用的 `agent-N`。`send_message` 和 `interrupt_agent` 使用 `name` 参数,接受发现到的路径或现有成员的 Session id。`list_agents` 在 ID 字段之外附加名字和父路径。实验性 Team 工具提供 `ownerName`;OAR 工具也返回名字。 -subagent 服务、provider 请求、Session catalog 和 SDK 句柄保留现有 ID 接口。运行时消息和完成通知仍通过 Session id 标识代理。命名插件记录自己的绑定事件。未挂载该插件时,普通委派和控制工具保留 ID 接口;实验性 Team 和 OAR 工具需要该插件。 +subagent 服务、provider 请求、Session catalog 和 SDK 句柄保留现有 ID 接口。运行时消息和完成通知仍通过 Session id 标识代理。命名插件在启动成功后将名字到 ID 的绑定记录在根 Session 中。子代理可能在名字绑定前开始第一步。未挂载该插件时,普通委派和控制工具保留 ID 接口;实验性 Team 和 OAR 工具需要该插件。 ## 迁移 1. 将脚本中的模型工具调用从 `send_message({ agent_id: id, ... })` 改为 `send_message({ name: id, ... })`,从 `interrupt_agent({ agent_id: id })` 改为 `interrupt_agent({ name: id })`。有返回的 `name` 时,后续调用使用该名字。 -2. 从已命名的创建和发现结果读取 `name`,从实验性 Team 任务结果读取 `ownerName`。现有 Host `startActivation`、`sendMessage`、`interrupt` 和 `ownerId` 调用保持不变。 -3. 在使用命名工具的自定义装配中,与 subagent 服务及 Session 查询、投影服务一起挂载 `@deepseek-ai/dsh-subagent-names`。随附的 base bundle 已提供该插件。 -4. 使用 `name: "reviewer"` 创建子代理,确认结果为 `lead/reviewer`,并确认 `list_agents` 和后续消息可以解析该路径。子代理仍在父 catalog 中时,不要复用其同级名字。 +2. 从创建和发现结果中读取原有 ID 及可选的 `name`,从实验性 Team 任务结果读取 `ownerName`。现有 Host `startActivation`、`sendMessage`、`interrupt` 和 `ownerId` 调用保持不变。 +3. 在使用命名工具的自定义装配中,与 subagent 服务及 Session 投影服务一起挂载 `@deepseek-ai/dsh-subagent-names`。随附的 base bundle 已提供该插件。 +4. 使用 `name: "reviewer"` 创建子代理,确认结果为 `lead/reviewer`,并确认 `list_agents` 和后续消息可以解析该路径。名字在根 Session 的整个生命周期内保留。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 43d11fd8f7..0b3809179d 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0f971f7ac7c09139 zh: ceb866da681c709c /deepseek-ai-dsh-base/use-this-package/what-you-get: - en: a232adcce61bc04f - zh: 1950dfa23b6d4c7f + en: 607df685b0817281 + zh: 779c7fcb1c1724ba /deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform: en: 2dce12f82d6ee955 zh: d4599eb899a3cd88 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index fa94bc39aa..3f8fa7bac5 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -49,7 +49,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval and can reach loopback and private-network hosts. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. -Both `subagent` and `subagent_fork` create managed children and return a full agent path immediately. The bundle mounts [Subagent names](../../subagent/subagent-names/README.md) for optional child names and name-based control tools. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. +Both `subagent` and `subagent_fork` create managed children and return a child id with its full agent path immediately. The bundle mounts [Subagent names](../../subagent/subagent-names/README.md) for optional child names and name-based control tools. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. The built-in `working_directory` tool changes the directory for subsequent operations while existing processes and write permissions remain independent. Current directory information appears in user context. Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 4ae85cb813..5c281def05 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -49,7 +49,7 @@ kind: "package-bundle" 开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,并且可以访问 loopback 与内网主机。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 -`subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回完整的 Agent 路径。bundle 挂载 [Subagent names](../../subagent/subagent-names/README.zh.md),提供可选的子任务名称和按名称寻址的控制工具。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 +`subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回子任务 ID 及其完整的 Agent 路径。bundle 挂载 [Subagent names](../../subagent/subagent-names/README.zh.md),提供可选的子任务名称和按名称寻址的控制工具。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 内置 `working_directory` 工具改变后续操作使用的目录,已有进程与写权限保持独立。当前目录信息出现在用户上下文中。默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: diff --git a/packages/experimental/tool-agent-team/README.i18n.yaml b/packages/experimental/tool-agent-team/README.i18n.yaml index 5feb84a323..d1985cb40f 100644 --- a/packages/experimental/tool-agent-team/README.i18n.yaml +++ b/packages/experimental/tool-agent-team/README.i18n.yaml @@ -21,26 +21,26 @@ en: c27d5457e1183675 zh: 3c36d513e6400446 /deepseek-ai-dsh-experimental-tool-agent-team/use-this-package/smallest-working-example: - en: 2d034608b7213085 - zh: 7b395feef826badb + en: 73e093d405599d3f + zh: c8be9cb03c2376e0 /deepseek-ai-dsh-experimental-tool-agent-team/use-this-package/what-the-model-can-do: - en: 66f5e2a8cbba3ef0 - zh: 6c4d7d5a25f2d8ca + en: d8294e282ee26b0d + zh: f6579be994043294 /deepseek-ai-dsh-experimental-tool-agent-team/use-this-package/what-success-and-failure-look-like: - en: fb745b864bb4a392 - zh: d3883a6f56bf4664 + en: f83d223649e1470d + zh: 071db9c5986383ed /deepseek-ai-dsh-experimental-tool-agent-team/understand-the-implementation: en: 3952eb1d9de1f988 zh: 789d51b88f41b710 /deepseek-ai-dsh-experimental-tool-agent-team/understand-the-implementation/design-philosophy: - en: 04791b530f48a523 - zh: 4f36247ea732a364 + en: 236b044c249412f6 + zh: b4886ad8eff97b8b /deepseek-ai-dsh-experimental-tool-agent-team/understand-the-implementation/source-map: en: 29ff96b23b25b13e zh: 7d2cdf9be86d1ed4 /deepseek-ai-dsh-experimental-tool-agent-team/understand-the-implementation/policy-and-tools: - en: 68956deedf9885ea - zh: 1d29d7dc5a6a6866 + en: 074f6c475a4ee627 + zh: 4804edb4cc77c355 /deepseek-ai-dsh-experimental-tool-agent-team/understand-the-implementation/scoped-registration-and-teardown: en: ef8a0940f658d225 zh: c13048bcda07d006 diff --git a/packages/experimental/tool-agent-team/README.md b/packages/experimental/tool-agent-team/README.md index 0adc60af40..c13ce2252c 100644 --- a/packages/experimental/tool-agent-team/README.md +++ b/packages/experimental/tool-agent-team/README.md @@ -43,7 +43,7 @@ The plugin takes no configuration. With the [Team domain](../agent-team/README.m A row that sets `interrupt`, `freshProvider`, or `forkProvider` does not activate, and dsh names the key in its startup warning. The [upgrade guide](../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md) maps the removed tools and keys to their replacements, and the generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-tool-agent-team) records the empty configuration. -Ask the Lead to start a subagent to review the diff, add a review task, and wait for its result. The `description` explains the subagent’s work; the returned full path addresses it. +Ask the Lead to start a subagent to review the diff, add a review task, and wait for its result. The `description` explains the subagent’s work; the returned name or Session id addresses it. ### What the model can do @@ -53,11 +53,11 @@ The task tools complement the ordinary subagent controls: - **Wait for inbox input** — `wait_agent` belongs to `@deepseek-ai/dsh-tool-subagent-control` and remains available without this package. - **Create and reach teammates** — `subagent`, `subagent_fork`, `send_message`, `list_agents`, and `interrupt_agent` come from the subagent tool packages and are unchanged by this package. -Reachable task owners use full agent paths in `owner` arguments and `ownerName` results. The root may reassign to itself or any reachable continuable descendant; historical roster records grant no authority. Other agents claim ready tasks themselves. Updates enforce ownership and revision checks. +Task owners use bound names or Session ids in `owner` arguments and `ownerName` results. The root may reassign to itself or any reachable continuable descendant; historical roster records grant no authority. Other agents claim ready tasks themselves. Updates enforce ownership and revision checks. ### What success and failure look like -Task tools return compact JSON task views or pages. Stale revisions, unauthorized actions, and unready tasks return errors. Updates resolve input and result-owner names before mutation; failed or incomplete name lookup leaves the task unchanged, and a committed result needs no further name lookup. A stored owner absent from a completely read tree renders as `legacy:`; unreadable branches fail the lookup instead of implying absence. This absent-owner marker cannot address a message or a reassignment target. +Task tools return compact JSON task views or pages. Stale revisions, unauthorized actions, and unready tasks return errors. Updates resolve input names and capture result-owner names before mutation. An unbound owner displays its original Session id; display names require no Session query or child catalog traversal. Name resolution grants no authority: the Team service still checks every reassignment target. ----- @@ -78,7 +78,7 @@ The adapter is built on four commitments: - **Declared results, compact JSON.** Every tool declares its complete result schema and renders that value as compact JSON, so the compiler checks `execute` against what the model is promised and no result spends tokens on indentation. - **The domain owns authority.** Tools delegate to `ctx.agentTeams`, which enforces Lead authority and revision checks; the adapter adds no weaker path. -The adapter uses `ctx.subagentNames` to resolve full paths while `ctx.agentTeams` retains Session ids for task ownership and authority. Task listing applies status, readiness, and unowned filters before resolving names. Without an exact owner-name filter, only the requested page needs name resolution; an exact owner-name filter resolves the remaining candidates before pagination. +The adapter reads the root Session’s name-to-id mapping synchronously through `ctx.subagentNames`; `ctx.agentTeams` retains Session ids for task ownership and authority. Task listing applies status, readiness, and unowned filters before resolving names. Without an exact owner-name filter, only the requested page needs name resolution; an exact owner-name filter resolves the remaining candidates before pagination. The [subagent teammates Agent Note](../../../.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md) owns the model-facing and scoping decisions. @@ -90,7 +90,7 @@ The [subagent teammates Agent Note](../../../.agents/notes/implemented/architect ### Policy and tools -One `team:policy` section, required `team:identity` context, and four task schemas are registered on each Agent scope, including fork children. Tree authority comes from `ctx.subagents.rootOf(agent)`; `ctx.subagentNames` supplies the displayed full path. If the root is unavailable, the context reports that shared tasks, subagent discovery, and messaging are unavailable; it does not infer a parent from Session lineage. Communication and waiting belong to [subagent controls](../../subagent/tool-subagent-control/README.md); the subagent runtime delivers final results. +One `team:policy` section, required `team:identity` context, and four task schemas are registered on each Agent scope, including fork children. Tree authority comes from `ctx.subagents.rootOf(agent)`; `ctx.subagentNames` supplies bound display names or original Session ids. If the root is unavailable, the context reports that shared tasks, subagent discovery, and messaging are unavailable; it does not infer a parent from Session lineage. Communication and waiting belong to [subagent controls](../../subagent/tool-subagent-control/README.md); the subagent runtime delivers final results. ### Scoped registration and teardown @@ -126,7 +126,7 @@ Every Team member's system prompt carries one `team:policy` section; the Lead an ##### Team policy ```markdown -A shared task board is available to every agent in your subagent tree. Task owners are full agent paths from list_agents. The root can reassign tasks to itself or any continuable descendant; other agents claim ready tasks themselves. +A shared task board is available to every agent in your subagent tree. Task owners use agent names from list_agents, or Session ids when no name is bound. The root can reassign tasks to itself or any continuable descendant; other agents claim ready tasks themselves. Read tasks with team_task_list or team_task_get, claim with the current revision, perform the work, then complete the task. Dependencies determine readiness; write scopes describe expected edits and overlapping scopes are advisory, not locks. Task readiness does not start an agent. diff --git a/packages/experimental/tool-agent-team/README.zh.md b/packages/experimental/tool-agent-team/README.zh.md index 1d4aead341..8ffbc0eec9 100644 --- a/packages/experimental/tool-agent-team/README.zh.md +++ b/packages/experimental/tool-agent-team/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference" 设置 `interrupt`、`freshProvider` 或 `forkProvider` 的配置行不会激活,dsh 会在启动警告中指出该键。[升级指南](../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md)把已移除的工具与键对应到替代方式,生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-tool-agent-team)记录空配置。 -让 Lead 启动子代理审查差异、创建审查任务并等待结果。`description` 说明子代理的工作;返回的完整路径用于寻址。 +让 Lead 启动子代理审查差异、创建审查任务并等待结果。`description` 说明子代理的工作;返回的名称或 Session id 用于寻址。 ### 模型能做什么 @@ -53,11 +53,11 @@ kind: "package-reference" - **等待收件箱输入** — `wait_agent` 属于 `@deepseek-ai/dsh-tool-subagent-control`,无需此包即可使用。 - **创建和联系队友**——`subagent`、`subagent_fork`、`send_message`、`list_agents` 和 `interrupt_agent` 来自子代理工具包,本包不修改它们。 -可达任务负责人在 `owner` 参数和 `ownerName` 结果中使用完整 agent 路径。根可将任务重新分配给自身或任意可达的可继续执行后代;历史名册记录不授予权限。其他 agent 自行认领就绪任务。更新会检查归属和修订。 +任务负责人在 `owner` 参数和 `ownerName` 结果中使用已绑定名称或 Session id。根可将任务重新分配给自身或任意可达的可继续执行后代;历史名册记录不授予权限。其他 agent 自行认领就绪任务。更新会检查归属和修订。 ### 成功与失败的表现 -任务工具返回紧凑的 JSON 任务视图或分页。过期修订、未授权操作和未就绪任务返回错误。更新在修改前解析输入与结果中的负责人名称;名称查询失败或不完整时任务保持不变,已提交结果无需再查询名称。完整读取树后仍不存在的已存储负责人显示为 `legacy:`;不可读分支使查询失败,不表示负责人不存在。这个缺席负责人标记不能用于发送消息或指定重新分配的目标。 +任务工具返回紧凑的 JSON 任务视图或分页。过期修订、未授权操作和未就绪任务返回错误。更新在修改前解析输入名称并保存结果中的负责人名称。未绑定名称的负责人显示原始 Session id;显示名称无需查询 Session 或遍历子级目录。名称解析不授予权限:Team 服务仍检查每个重新分配的目标。 ----- @@ -78,7 +78,7 @@ kind: "package-reference" - **声明式结果,紧凑 JSON。** 每个工具都声明完整结果 schema,并把该值渲染为紧凑 JSON,因此编译器会对照向模型承诺的结果检查 `execute`,任何结果都不会在缩进上消耗 token。 - **领域掌握裁决权。** 工具委托给 `ctx.agentTeams`,后者强制执行 Lead 权限与 revision 校验;适配器不添加更弱的路径。 -适配器使用 `ctx.subagentNames` 解析完整路径,`ctx.agentTeams` 继续使用 Session id 处理任务归属和权限。任务列表先按状态、就绪情况和未分配条件筛选,再解析名称。没有精确负责人名称筛选时,只解析请求页中的名称;按精确负责人名称筛选时,在分页前解析剩余候选任务的负责人名称。 +适配器通过 `ctx.subagentNames` 同步读取根 Session 中的名称与 id 映射;`ctx.agentTeams` 继续使用 Session id 处理任务归属和权限。任务列表先按状态、就绪情况和未分配条件筛选,再解析名称。没有精确负责人名称筛选时,只解析请求页中的名称;按精确负责人名称筛选时,在分页前解析剩余候选任务的负责人名称。 [subagent teammate Agent Note](../../../.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md)负责模型侧与 scoping 决策。 @@ -90,7 +90,7 @@ kind: "package-reference" ### 策略与工具 -每个 Agent 作用域均注册一个 `team:policy` 段落、必需的 `team:identity` 上下文和四个任务 schema,包括 fork 子级。树权限从 `ctx.subagents.rootOf(agent)` 派生;`ctx.subagentNames` 提供显示的完整路径。根不可用时,上下文说明共享任务、subagent 发现与通信不可用,不从 Session 创建来源推断父代理。通信和等待属于 [subagent 控制工具](../../subagent/tool-subagent-control/README.zh.md);subagent 运行时负责投递最终结果。 +每个 Agent 作用域均注册一个 `team:policy` 段落、必需的 `team:identity` 上下文和四个任务 schema,包括 fork 子级。树权限从 `ctx.subagents.rootOf(agent)` 派生;`ctx.subagentNames` 提供已绑定的显示名称或原始 Session id。根不可用时,上下文说明共享任务、subagent 发现与通信不可用,不从 Session 创建来源推断父代理。通信和等待属于 [subagent 控制工具](../../subagent/tool-subagent-control/README.zh.md);subagent 运行时负责投递最终结果。 ### 按作用域注册与拆除 @@ -126,7 +126,7 @@ kind: "package-reference" ##### Team 策略 ```markdown -A shared task board is available to every agent in your subagent tree. Task owners are full agent paths from list_agents. The root can reassign tasks to itself or any continuable descendant; other agents claim ready tasks themselves. +A shared task board is available to every agent in your subagent tree. Task owners use agent names from list_agents, or Session ids when no name is bound. The root can reassign tasks to itself or any continuable descendant; other agents claim ready tasks themselves. Read tasks with team_task_list or team_task_get, claim with the current revision, perform the work, then complete the task. Dependencies determine readiness; write scopes describe expected edits and overlapping scopes are advisory, not locks. Task readiness does not start an agent. diff --git a/packages/experimental/tool-agent-team/src/index.ts b/packages/experimental/tool-agent-team/src/index.ts index 96391b25b9..f5faac96f7 100644 --- a/packages/experimental/tool-agent-team/src/index.ts +++ b/packages/experimental/tool-agent-team/src/index.ts @@ -25,7 +25,7 @@ export const Config: z = z.object({}) const REMOVED_CONFIG_KEYS = ['interrupt', 'freshProvider', 'forkProvider'] as const /** Model-facing collaboration guidance shared by Lead and teammates. */ -const TEAM_POLICY = `A shared task board is available to every agent in your subagent tree. Task owners are full agent paths from list_agents. The root can reassign tasks to itself or any continuable descendant; other agents claim ready tasks themselves. +const TEAM_POLICY = `A shared task board is available to every agent in your subagent tree. Task owners use agent names from list_agents, or Session ids when no name is bound. The root can reassign tasks to itself or any continuable descendant; other agents claim ready tasks themselves. Read tasks with team_task_list or team_task_get, claim with the current revision, perform the work, then complete the task. Dependencies determine readiness; write scopes describe expected edits and overlapping scopes are advisory, not locks. Task readiness does not start an agent. @@ -77,34 +77,22 @@ function jsonOutput(schema: S): { type NamedTaskView = Omit & { readonly ownerName?: string } -/** Read immutable owner names before a mutation or for the requested result rows. */ -async function taskOwnerNames( - ctx: Context, caller: Agent, owners: readonly (SessionId | undefined)[], signal: AbortSignal, -): Promise> { - const root = ctx.subagents.rootOf(caller) - const names = new Map([[root.id, 'lead']]) - const missing = new Set() +/** Capture owner display names before a mutation or for the requested result rows. */ +function taskOwnerNames( + ctx: Context, caller: Agent, owners: readonly (SessionId | undefined)[], +): ReadonlyMap { + const names = new Map() for (const ownerId of owners) { - if (ownerId === undefined || names.has(ownerId)) continue - const agent = ctx.agents.get(ownerId) - if (agent === undefined) missing.add(ownerId) - else names.set(ownerId, await ctx.subagentNames.nameOf(agent, signal)) - } - if (missing.size > 0) { - const descendants = await ctx.subagentNames.listDescendants(root.id, signal) - for (const entry of descendants) names.set(entry.id, `lead/${entry.path}`) - if ([...missing].some(id => !names.has(id)) && descendants.some(entry => entry.kind === 'diagnostic')) { - throw new Error('Task owner names are unavailable because subagent discovery is incomplete; restore the unreadable branch and retry.') - } + if (ownerId !== undefined && !names.has(ownerId)) names.set(ownerId, ctx.subagentNames.nameOf(caller, ownerId)) } return names } -/** Format a task without querying mutable services after its commit. */ +/** Format a task from names captured for each possible result owner before its commit. */ function namedTask({ ownerId, ...view }: TeamTaskView, names: ReadonlyMap): NamedTaskView { return { ...view, - ...ownerId === undefined ? {} : { ownerName: names.get(ownerId) ?? `legacy:${ownerId}` }, + ...ownerId === undefined ? {} : { ownerName: names.get(ownerId) as string }, } } @@ -184,7 +172,7 @@ function install(agent: Agent, ctx: Context): () => void { limit: { type: 'integer', description: 'Number of rows, 1 through 100. Defaults to 50.' }, }, output: jsonOutput(TASK_LIST_VALUE_SCHEMA), - async execute(args, exec) { + execute(args, exec) { const cursor = args.cursor ?? 0 const limit = args.limit ?? 50 if (!Number.isSafeInteger(cursor) || cursor < 0) throw new Error('cursor must be a non-negative safe integer') @@ -196,12 +184,12 @@ function install(agent: Agent, ctx: Context): () => void { && (args.ready === undefined || task.ready === args.ready)) const filterOwner = args.owner !== undefined && args.owner !== 'unowned' const candidates = filterOwner ? filtered : filtered.slice(cursor, cursor + limit) - const names = await taskOwnerNames(ctx, caller, candidates.map(task => task.ownerId), exec.signal) + const names = taskOwnerNames(ctx, caller, candidates.map(task => task.ownerId)) if (filterOwner) filtered = filtered.filter(task => namedTask(task, names).ownerName === args.owner) - return { + return Promise.resolve({ tasks: filtered.slice(cursor, cursor + limit).map(task => namedTask(task, names)), ...(cursor + limit < filtered.length ? { nextCursor: cursor + limit } : {}), - } + }) }, }))) @@ -212,11 +200,11 @@ function install(agent: Agent, ctx: Context): () => void { task_id: { type: 'string', required: true, description: 'Shared task id.' }, }, output: jsonOutput(TASK_VIEW_SCHEMA), - async execute(args, exec) { + execute(args, exec) { const caller = callingAgent(exec.agent, 'team_task_get') const task = ctx.agentTeams.getTask(caller, TeamTaskId(args.task_id)) - const names = await taskOwnerNames(ctx, caller, [task.ownerId], exec.signal) - return namedTask(task, names) + const names = taskOwnerNames(ctx, caller, [task.ownerId]) + return Promise.resolve(namedTask(task, names)) }, }))) @@ -236,7 +224,7 @@ function install(agent: Agent, ctx: Context): () => void { description: { type: 'string', description: 'Replacement details for edit.' }, blocked_by: { type: 'array', items: { type: 'string' }, description: 'Complete blocker list for set_dependencies.' }, write_scopes: { type: 'array', items: { type: 'string' }, description: 'Replacement advisory write scopes for edit.' }, - owner: { type: 'string', description: 'Full agent path of the root or any continuable descendant for root-only reassign; omit to unassign.' }, + owner: { type: 'string', description: 'Agent name or Session id of the root or any continuable descendant for root-only reassign; omit to unassign.' }, }, output: jsonOutput(TASK_VIEW_SCHEMA), async execute(args, exec) { @@ -244,12 +232,12 @@ function install(agent: Agent, ctx: Context): () => void { const taskId = TeamTaskId(args.task_id) const current = ctx.agentTeams.getTask(caller, taskId) const owner = args.owner === undefined ? undefined - : await ctx.subagentNames.resolveName(caller, args.owner, exec.signal) + : ctx.subagentNames.resolveName(caller, args.owner) const keepsOwner = !['claim', 'release', 'reopen', 'reassign'].includes(args.action) // Names are immutable; the service still checks the revision and authority after these reads. - const names = await taskOwnerNames(ctx, caller, [ + const names = taskOwnerNames(ctx, caller, [ caller.id, owner, keepsOwner ? current.ownerId : undefined, - ], exec.signal) + ]) const task = await ctx.agentTeams.updateTask(caller, { taskId, expectedRevision: args.expected_revision, diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 20b4622b54..9edd3d02f4 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -82,7 +82,7 @@ async function setup(script: ConstructorParameters[0]) { const queryFiber = await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - await ctx.plugin(SubagentNames) + const namesFiber = await ctx.plugin(SubagentNames) await ctx.plugin(ToolSubagentControl) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -91,7 +91,7 @@ async function setup(script: ConstructorParameters[0]) { const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) const lead = await ctx.agentLoop.create(SessionId('tool-team-lead'), { provider: 'mock', model: 'mock' }) - return { ctx, lead, fiber, adapter, queryFiber } + return { ctx, lead, fiber, adapter, queryFiber, namesFiber } } function execute( @@ -128,16 +128,22 @@ async function startWait(ctx: Context, agent: Agent) { return { result } } -/** Start a continuable child of the Lead with a task description. */ +/** Start a continuable child and record its display name after acceptance. */ async function spawn(ctx: Context, lead: Agent, description: string | undefined, prompt: string, provider = 'spawn'): Promise { - const started = await ctx.subagentNames.startActivation({ name: `child-${randomUUID()}`, - provider, - label: `${description ?? 'shared'} task`, - prompt: [{ type: 'text', text: prompt }], parent: lead, - signal: SIGNAL, - delivery: 'parent', - }) - return started + const reservation = ctx.subagentNames.reserve(lead, `child-${randomUUID()}`) + try { + const started = await ctx.subagents.startActivation({ + provider, + label: `${description ?? 'shared'} task`, + prompt: [{ type: 'text', text: prompt }], parent: lead, + signal: SIGNAL, + delivery: 'parent', + }) + ctx.subagentNames.bind(lead, started.childId, reservation.name) + return { ...started, name: reservation.name } + } finally { + reservation.dispose() + } } async function assembly(ctx: Context, agent: Agent) { @@ -171,25 +177,9 @@ async function waitNoAgent(ctx: Context, id: SessionId): Promise { await vi.waitFor(() => { expect(ctx.agents.get(id)).toBeUndefined() }, { timeout: 5_000 }) } -/** Publish the catalog membership required by named discovery for synthetic child Agents. */ -function bindTeamChild(ctx: Context, child: Agent, root: Agent, ancestors: readonly SessionId[] = [root.id]): void { - bindSubagentActivation(ctx, child, root, ancestors) - const parentId = ancestors.at(-1) - const parent = parentId === undefined ? undefined : ctx.agents.get(parentId) - if (parent === undefined) throw new Error('expected a live fixture creator') - parent.session.append('subagent/catalog', { - version: 0, childId: child.id, childCreatedAt: child.session.header.createdAt, - mode: 'continuable', label: 'Fixture child', - }) -} - -/** Assign a task to a child whose name must be read from its durable catalog. */ +/** Assign a task to a settled child with a persisted name binding. */ async function taskWithSettledOwner(ctx: Context, lead: Agent) { - const activation = await ctx.subagentNames.startActivation({ - name: 'worker', provider: 'spawn', label: 'Review', - parent: lead, prompt: [{ type: 'text', text: 'Review the change' }], - signal: SIGNAL, delivery: 'parent', - }) + const activation = await spawn(ctx, lead, 'worker', 'Review the change') try { await activation.result } finally { @@ -220,7 +210,7 @@ describe('dsh-tool-team', () => { }) const controller = new AbortController() const result = execute(ctx, lead, 'team_task_update', { - task_id: task.id, expected_revision: task.revision, action: 'reassign', owner: await ctx.subagentNames.nameOf(ctx.agents.get(childId)!), + task_id: task.id, expected_revision: task.revision, action: 'reassign', owner: ctx.subagentNames.nameOf(ctx.agents.get(childId)!), }, controller.signal) try { await entered.promise @@ -321,15 +311,15 @@ describe('dsh-tool-team', () => { const child = await ctx.agents.create({ sessionId: SessionId('identity-child'), meta: { parentSession: lead.id, origin: 'subagent' }, - setup: (_ctx, agent) => { bindTeamChild(ctx, agent, lead) }, + setup: (_ctx, agent) => { bindSubagentActivation(ctx, agent, lead) }, }) const nested = await ctx.agents.create({ sessionId: SessionId('identity-nested'), meta: { parentSession: child.agent.id, origin: 'subagent' }, - setup: (_ctx, agent) => { bindTeamChild(ctx, agent, lead, [lead.id, child.agent.id]) }, + setup: (_ctx, agent) => { bindSubagentActivation(ctx, agent, lead, [lead.id, child.agent.id]) }, }) expect(renderContextSnapshot(await assembly(ctx, nested.agent))).toContain(teammateIdentity(lead)) - expect(renderContextSnapshot(await assembly(ctx, nested.agent))).not.toContain(`Team led by ${JSON.stringify(await ctx.subagentNames.nameOf(child.agent))}`) + expect(renderContextSnapshot(await assembly(ctx, nested.agent))).not.toContain(`Team led by ${JSON.stringify(ctx.subagentNames.nameOf(child.agent))}`) }) it.each([undefined, 'in-history'] as const)('appends current Lead identity to a teammate fork with update mode %s', async (systemPromptUpdate) => { @@ -444,7 +434,7 @@ describe('dsh-tool-team', () => { expected_revision: task.revision, action: 'claim', }) - expect(JSON.parse(text(claimed))).toMatchObject({ status: 'in_progress', ownerName: await ctx.subagentNames.nameOf(ctx.agents.get(childId)!) }) + expect(JSON.parse(text(claimed))).toMatchObject({ status: 'in_progress', ownerName: ctx.subagentNames.nameOf(ctx.agents.get(childId)!) }) const stale = await execute(ctx, lead, 'team_task_update', { task_id: task.id, expected_revision: task.revision, @@ -520,7 +510,7 @@ describe('dsh-tool-team', () => { task_id: first.id, expected_revision: dependency.revision, action: 'reassign', - owner: await ctx.subagentNames.nameOf(ctx.agents.get(childId)!), + owner: ctx.subagentNames.nameOf(ctx.agents.get(childId)!), })).isError).toBe(true) const child = ctx.agents.get(childId)! @@ -534,7 +524,7 @@ describe('dsh-tool-team', () => { await waitNoAgent(ctx, childId) }) - it('retains a settled task owner’s catalog name and rejects its legacy alias', async () => { + it('retains a settled task owner’s bound name and rejects its legacy alias', async () => { const { ctx, lead } = await setup([textResponse('done')]) const { childId: id, name } = await spawn(ctx, lead, 'reviewer', 'review') await waitNoAgent(ctx, id) @@ -550,7 +540,7 @@ describe('dsh-tool-team', () => { })).isError).toBe(true) }) - it('leaves a task unchanged when its owner name cannot be read before an edit', async () => { + it('edits named task owners without Session queries', async () => { const { ctx, lead, queryFiber } = await setup([textResponse('done')]) const { task } = await taskWithSettledOwner(ctx, lead) await queryFiber.dispose() @@ -558,20 +548,20 @@ describe('dsh-tool-team', () => { const result = await execute(ctx, lead, 'team_task_update', { task_id: task.id, expected_revision: task.revision, action: 'edit', subject: 'Edited', }) - expect(result.isError).toBe(true) - expect(text(result)).toContain('listing subagents requires the sessionQuery service') - expect(ctx.agentTeams.getTask(lead, task.id)).toEqual(task) + expect(result.isError).toBe(false) + expect(JSON.parse(text(result))).toMatchObject({ subject: 'Edited', ownerName: ctx.subagentNames.nameOf(lead, task.ownerId) }) + expect(ctx.agentTeams.getTask(lead, task.id)).toMatchObject({ subject: 'Edited', revision: task.revision + 1 }) await using stored = await ctx.sessionPersistence.open(lead.id, 'read') - expect((await stored.read()).events.filter(event => event.type === 'team/task')).toHaveLength(2) + expect((await stored.read()).events.filter(event => event.type === 'team/task')).toHaveLength(3) }) - it('returns the committed task when Session queries unload during the commit', async () => { - const { ctx, lead, queryFiber } = await setup([textResponse('done')]) + it('returns captured owner names when the naming plugin unloads during the commit', async () => { + const { ctx, lead, namesFiber } = await setup([textResponse('done')]) const { task, ownerName } = await taskWithSettledOwner(ctx, lead) const flush = ctx.sessions.flush.bind(ctx.sessions) vi.spyOn(ctx.sessions, 'flush').mockImplementationOnce(async (session) => { const flushed = await flush(session) - await queryFiber.dispose() + await namesFiber.dispose() return flushed }) @@ -583,42 +573,26 @@ describe('dsh-tool-team', () => { expect(ctx.agentTeams.getTask(lead, task.id)).toMatchObject({ subject: 'Edited', revision: task.revision + 1 }) }) - it('rejects an edit if another writer changes the task while its owner name is being read', async () => { - const { ctx, lead } = await setup([textResponse('done')]) - const { task } = await taskWithSettledOwner(ctx, lead) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() - const discover = ctx.subagentNames.listDescendants.bind(ctx.subagentNames) - vi.spyOn(ctx.subagentNames, 'listDescendants').mockImplementationOnce(async (...args) => { - const entries = await discover(...args) - entered.resolve(undefined) - await release.promise - return entries + it('does not grant task authority through a name binding for an unrelated Agent', async () => { + const { ctx, lead } = await setup([]) + const unrelated = await ctx.agents.create({ + sessionId: SessionId('unrelated-task-owner'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const result = execute(ctx, lead, 'team_task_update', { - task_id: task.id, expected_revision: task.revision, action: 'edit', subject: 'Delayed edit', - }) - try { - await entered.promise - expect(ctx.agentTeams.getTask(lead, task.id)).toEqual(task) - await ctx.agentTeams.updateTask(lead, { - taskId: task.id, expectedRevision: task.revision, action: 'edit', subject: 'Concurrent edit', - }) - } finally { - release.resolve(undefined) - } - expect((await result).isError).toBe(true) - expect(text(await result)).toContain('stale team task') - expect(ctx.agentTeams.getTask(lead, task.id)).toMatchObject({ - subject: 'Concurrent edit', revision: task.revision + 1, + ctx.subagentNames.bind(lead, unrelated.agent.id, 'lead/not-a-teammate') + const task = await ctx.agentTeams.createTask(lead, { subject: 'Review', description: 'Requires a teammate' }) + const result = await execute(ctx, lead, 'team_task_update', { + task_id: task.id, expected_revision: task.revision, action: 'reassign', owner: 'lead/not-a-teammate', }) + expect(result.isError).toBe(true) + expect(ctx.agentTeams.getTask(lead, task.id)).toEqual(task) + await unrelated.dispose() }) it('reads owner names only for rows surviving filters and pagination', async () => { const { ctx, lead } = await setup([textResponse('done')]) const unowned = await ctx.agentTeams.createTask(lead, { subject: 'First', description: 'Unowned' }) const { task, ownerName } = await taskWithSettledOwner(ctx, lead) - const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const observe = vi.spyOn(ctx.subagentNames, 'nameOf') for (const filter of [{ owner: 'unowned' }, { status: 'pending' }, { ready: true }, { limit: 1 }]) { const result = await execute(ctx, lead, 'team_task_list', filter) expect(result.isError).toBe(false) @@ -648,16 +622,18 @@ describe('dsh-tool-team', () => { ? await ctx.agentTeams.updateTask(lead, { taskId: task.id, expectedRevision: task.revision, action: 'complete' }) : task await queryFiber.dispose() + const nameOf = vi.spyOn(ctx.subagentNames, 'nameOf') const result = await execute(ctx, lead, 'team_task_update', { task_id: task.id, expected_revision: current.revision, action, ...action === 'reassign' ? { owner: 'lead' } : {}, }) expect(result.isError).toBe(false) + expect(nameOf.mock.calls.some(([, id]) => id === task.ownerId)).toBe(false) if (action === 'reassign') expect(JSON.parse(text(result))).toMatchObject({ ownerName: 'lead' }) else expect(JSON.parse(text(result))).not.toHaveProperty('ownerName') }) - it('retains an uncataloged historical task owner as a legacy id', async () => { + it('displays an unnamed historical task owner by its Session id', async () => { const { ctx, lead } = await setup([]) const task = await ctx.agentTeams.createTask(lead, { subject: 'Review', description: 'Read old ownership' }) const { ready: _ready, writeScopeWarnings: _warnings, ...saved } = task @@ -666,10 +642,10 @@ describe('dsh-tool-team', () => { task: { ...saved, revision: 2, ownerId: SessionId('missing-worker') }, }) expect(JSON.parse(text(await execute(ctx, lead, 'team_task_get', { task_id: task.id })))) - .toMatchObject({ ownerName: 'legacy:missing-worker' }) + .toMatchObject({ ownerName: 'missing-worker' }) }) - it('rejects a task edit when an unreadable branch may contain its owner', async () => { + it('edits an unnamed task owner without traversing unreadable child catalogs', async () => { const { ctx, lead } = await setup([]) const created = await ctx.agentTeams.createTask(lead, { subject: 'Review', description: 'Read old ownership' }) const { ready: _ready, writeScopeWarnings: _warnings, ...saved } = created @@ -685,10 +661,10 @@ describe('dsh-tool-team', () => { const result = await execute(ctx, lead, 'team_task_update', { task_id: task.id, expected_revision: task.revision, action: 'edit', subject: 'Edited', }) - expect(result.isError).toBe(true) - expect(text(result)).toContain('subagent discovery is incomplete') - expect(ctx.agentTeams.getTask(lead, task.id)).toEqual(task) - expect(lead.session.ownEvents().filter(event => event.type === 'team/task')).toHaveLength(2) + expect(result.isError).toBe(false) + expect(JSON.parse(text(result))).toMatchObject({ subject: 'Edited', ownerName: 'unobserved-worker' }) + expect(ctx.agentTeams.getTask(lead, task.id)).toMatchObject({ subject: 'Edited', revision: task.revision + 1 }) + expect(lead.session.ownEvents().filter(event => event.type === 'team/task')).toHaveLength(3) }) it.each([false, true])('rejects historical roster assignments without a readable continuable target (cataloged: %s)', async (cataloged) => { @@ -702,11 +678,11 @@ describe('dsh-tool-team', () => { const created = await ctx.agentTeams.createTask(lead, { subject: 'Review', description: 'Review history' }) expect((await execute(ctx, lead, 'team_task_update', { task_id: created.id, expected_revision: created.revision, action: 'reassign', - owner: cataloged ? 'lead/legacy:historical-worker' : 'legacy:historical-worker', + owner: id, })).isError).toBe(true) - expect((await execute(ctx, lead, 'send_message', { name: 'legacy:historical-worker', message: 'hello' })).isError).toBe(true) + expect((await execute(ctx, lead, 'send_message', { name: id, message: 'hello' })).isError).toBe(true) expect((await execute(ctx, lead, 'team_task_update', { - task_id: created.id, expected_revision: 2, action: 'reassign', owner: 'legacy:missing', + task_id: created.id, expected_revision: 2, action: 'reassign', owner: 'missing', })).isError).toBe(true) }) @@ -748,7 +724,7 @@ describe('dsh-tool-team', () => { const child = await ctx.agents.create({ sessionId: id, agentOptions: { provider: 'mock', model: 'mock' }, meta: { origin: 'subagent', parentSession: lead.id }, - setup: (_ctx, agent) => { bindTeamChild(ctx, agent, lead) }, + setup: (_ctx, agent) => { bindSubagentActivation(ctx, agent, lead) }, }) expect(teamTools(await assembly(ctx, child.agent))).toEqual(TEAM_TOOLS) const created = await execute(ctx, child.agent, 'team_task_create', { @@ -767,7 +743,7 @@ describe('dsh-tool-team', () => { const child = await ctx.agents.create({ sessionId: SessionId('waiting-child'), agentOptions: { provider: 'mock', model: 'mock' }, meta: { origin: 'subagent', parentSession: lead.id }, - setup: (_ctx, agent) => { bindTeamChild(ctx, agent, lead) }, + setup: (_ctx, agent) => { bindSubagentActivation(ctx, agent, lead) }, }) const unrelated = await ctx.agents.create({ sessionId: SessionId('unrelated-root'), agentOptions: { provider: 'mock', model: 'mock' }, diff --git a/packages/experimental/tool-subagent-oar/README.i18n.yaml b/packages/experimental/tool-subagent-oar/README.i18n.yaml index 3c97ec83bd..b5c8367aa9 100644 --- a/packages/experimental/tool-subagent-oar/README.i18n.yaml +++ b/packages/experimental/tool-subagent-oar/README.i18n.yaml @@ -9,8 +9,8 @@ en: 520d9dc51e2b0142 zh: 8f332776243107a9 /deepseek-ai-dsh-experimental-tool-subagent-oar/summary: - en: c04d4c7d89864380 - zh: 9c1724f34943c24a + en: 2d63ab94ccdafa8f + zh: 6372823aea399513 /deepseek-ai-dsh-experimental-tool-subagent-oar/table-of-contents: en: d152484eb41ac6b4 zh: 8d1e20b826450ffe @@ -24,11 +24,11 @@ en: 7777b50b62aa53c0 zh: ff2a53de5dcbf518 /deepseek-ai-dsh-experimental-tool-subagent-oar/understand-the-implementation: - en: cbfa18477278437a - zh: c7135dccdc611892 + en: e168a1ffb8497b1a + zh: e9085b004a2d2c55 /deepseek-ai-dsh-experimental-tool-subagent-oar/further-exploration: - en: f85bd24e012ceb8b - zh: a6c09ed212b52745 + en: 8aeb6cf566b48838 + zh: cc0f5bb71ee7a9fe /deepseek-ai-dsh-experimental-tool-subagent-oar/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -36,8 +36,8 @@ en: 8febbb031aa0452d zh: da3f9011631652da /deepseek-ai-dsh-experimental-tool-subagent-oar/model-experience/external-delegation-tool/what-the-model-sees: - en: 9fd8beb18063ef0d - zh: 2fea6cefb5bcf19f + en: 4b2fc7303598e643 + zh: efaa45f0c42befd2 /deepseek-ai-dsh-experimental-tool-subagent-oar/model-experience/external-delegation-tool/token-effect: en: 60d7ffec7f4dc858 zh: 09f286978df6b4ec @@ -45,8 +45,8 @@ en: 7c1ac0b007b2f0ce zh: 2332a07c6f8bcbdc /deepseek-ai-dsh-experimental-tool-subagent-oar/known-limitations-and-deferred-work: - en: 5157ca8c4aa1a865 - zh: 47ad253d87a02838 + en: e0ed82ba366c0f17 + zh: c7fd3d1b48bb3c11 /deepseek-ai-dsh-experimental-tool-subagent-oar/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 8ec3e73528ddb1a6 diff --git a/packages/experimental/tool-subagent-oar/README.md b/packages/experimental/tool-subagent-oar/README.md index b9f2762785..fded6c3bfe 100644 --- a/packages/experimental/tool-subagent-oar/README.md +++ b/packages/experimental/tool-subagent-oar/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use one `subagent_oar` tool to choose a configured external agent for each standalone text task. Configuration fixes the available runtime aliases and their providers; the model selects an alias without changing native arguments or permissions. Each task uses independent context and returns a full child path, with a later completion notice delivered to the parent. +Use one `subagent_oar` tool to choose a configured external agent for each standalone text task. Configuration fixes the available runtime aliases and their providers; the model selects an alias without changing native arguments or permissions. Each task uses independent context and returns its child id and full name, with a later completion notice delivered to the parent. ## Table of Contents @@ -59,7 +59,7 @@ The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-exper
Implementation internals — click to expand -[`src/index.ts`](src/index.ts) builds the runtime enum from the configured aliases and registers one tool. Each call resolves its provider and starts a named activation through `ctx.subagentNames`, which wraps `ctx.subagents.startActivation()`. The subagent service owns capacity, parent lifetime, cancellation, terminal recording, and completion delivery. The tool's call card uses the task description and submitted arguments. Disposing the plugin removes its tool registration; the activation manager retains ownership of accepted children. +[`src/index.ts`](src/index.ts) registers the configured runtime choices and calls `ctx.subagents.startActivation()` directly. The naming service reserves a sibling name and maps the accepted child id to its full name; the child's prompt is the submitted task. The subagent service owns capacity, parent lifetime, cancellation, terminal recording, and completion delivery. The tool's call card uses the task description and submitted arguments. Disposing the plugin removes its tool registration; the activation manager retains ownership of accepted children.
@@ -69,7 +69,7 @@ The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-exper ## Further Exploration - [Subagent service](../../subagent/subagent/README.md) — provider registration and activation ownership. -- [Subagent names](../../subagent/subagent-names/README.md) — sibling names and full-path results. +- [Subagent names](../../subagent/subagent-names/README.md) — sibling names and full-name lookup. - [OAR provider](../subagent-oar/README.md) — native runtime setup and permission configuration. - [Subagent subsystem](../../../docs/subsystems/subagent.md) — parent completion notices and retained results. @@ -82,7 +82,7 @@ The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-exper #### What the model sees -The [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-experimental-tool-subagent-oar) owns the description and argument schema. The `runtime` enum contains the configured aliases. After acceptance, the tool returns `started subagent `, where `` is the full child path. The shared subagent service later supplies the child's answer or failure notice, identifying the external execution by its child id. +The [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-experimental-tool-subagent-oar) owns the description and argument schema. The `runtime` enum contains the configured aliases. After acceptance, the result contains `kind: "activation"`, `subagentId`, and `name`. The model sees `started subagent ` followed by `name: ` on the next line. The shared subagent service later supplies the child's answer or failure notice, identifying the external execution by the same child id. #### Token effect @@ -100,6 +100,7 @@ The tool's choices and execution capabilities are fixed by its configured provid - **One-shot providers only:** child context is independent, and the tool exposes no follow-up, resume, structured output, or per-call native permission override. - **Provider availability:** a selected provider that is absent or unloaded fails at call time; its configured alias remains in the schema until this tool's configuration changes. +- **Name binding failure:** if the accepted child's name cannot be recorded, the error reports its id and leaves execution managed by the subagent service. Check that child's outcome before retrying; the reserved name is released. - **Provider-owned work:** runtime setup, startup cancellation, process cleanup, and answer selection follow the selected provider's documented behavior. Removing this tool does not revoke accepted activations. diff --git a/packages/experimental/tool-subagent-oar/README.zh.md b/packages/experimental/tool-subagent-oar/README.zh.md index 7bb48e74ca..0542a1ab40 100644 --- a/packages/experimental/tool-subagent-oar/README.zh.md +++ b/packages/experimental/tool-subagent-oar/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用一个 `subagent_oar` 工具,为每个独立文本任务选择已配置的外部智能体。配置固定可用的运行时别名及其提供方;模型选择别名,不能更改原生参数或权限。每个任务使用独立上下文并返回子级完整路径,完成通知稍后送达父智能体。 +使用一个 `subagent_oar` 工具,为每个独立文本任务选择已配置的外部智能体。配置固定可用的运行时别名及其提供方;模型选择别名,不能更改原生参数或权限。每个任务使用独立上下文并返回子任务 id 和完整名称,完成通知稍后送达父智能体。 ## 目录 @@ -59,7 +59,7 @@ kind: "package-reference"
实现内部细节 — 点击展开 -[`src/index.ts`](src/index.ts)根据配置的别名构建运行时枚举,并注册一个工具。每次调用解析其提供方,并通过 `ctx.subagentNames` 启动具名激活;命名服务包装 `ctx.subagents.startActivation()`。subagent 服务负责容量、父智能体生命周期、取消、最终记录和完成通知交付。工具调用卡片使用任务描述和提交的参数。释放插件会移除其工具注册;激活管理器继续负责已接受的子任务。 +[`src/index.ts`](src/index.ts)注册配置的运行时选项,并直接调用 `ctx.subagents.startActivation()`。命名服务预留同级名称,并将已接受的子任务 id 映射到完整名称;子任务的提示就是提交的任务。subagent 服务负责容量、父智能体生命周期、取消、最终记录和完成通知交付。工具调用卡片使用任务描述和提交的参数。释放插件会移除其工具注册;激活管理器继续负责已接受的子任务。
@@ -69,7 +69,7 @@ kind: "package-reference" ## 延伸阅读 - [子智能体服务](../../subagent/subagent/README.zh.md) — 提供方注册和激活所有权。 -- [Subagent 命名](../../subagent/subagent-names/README.zh.md) — 同级名字和完整路径结果。 +- [Subagent 命名](../../subagent/subagent-names/README.zh.md) — 同级名称和完整名称查询。 - [OAR 提供方](../subagent-oar/README.zh.md) — 原生运行时配置和权限配置。 - [子智能体子系统](../../../docs/subsystems/subagent.zh.md) — 父智能体完成通知和保留结果。 @@ -82,7 +82,7 @@ kind: "package-reference" #### 模型看到什么 -[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-experimental-tool-subagent-oar)定义描述和参数 schema。`runtime` 枚举包含配置的别名。接受任务后,工具返回 `started subagent `,其中 `` 是子级完整路径。共享子智能体服务稍后提供子任务的答复或失败通知,以子任务 id 标识外部执行。 +[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-experimental-tool-subagent-oar)定义描述和参数 schema。`runtime` 枚举包含配置的别名。接受任务后,结果包含 `kind: "activation"`、`subagentId` 和 `name`。模型看到 `started subagent `,下一行是 `name: `。共享子智能体服务稍后提供子任务的答复或失败通知,以相同的子任务 id 标识外部执行。 #### Token 影响 @@ -100,6 +100,7 @@ kind: "package-reference" - **仅支持单次提供方:** 子任务上下文独立,工具不提供后续输入、恢复、结构化输出或按调用覆盖原生权限的功能。 - **提供方可用性:** 选择的提供方不存在或已卸载时,调用会失败;其配置的别名会保留在 schema 中,直到此工具的配置改变。 +- **名称绑定失败:** 如果已接受的子任务名称无法记录,错误会报告其 id,执行仍由 subagent 服务管理。重试前应检查该子任务的结果;预留的名称会被释放。 - **提供方负责的工作:** 运行时配置、启动取消、进程清理和答复选择遵循所选提供方的文档行为。移除此工具不会撤销已接受的激活。 diff --git a/packages/experimental/tool-subagent-oar/package.json b/packages/experimental/tool-subagent-oar/package.json index 31654c3af6..3a3f754b4f 100644 --- a/packages/experimental/tool-subagent-oar/package.json +++ b/packages/experimental/tool-subagent-oar/package.json @@ -30,6 +30,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-names": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" diff --git a/packages/experimental/tool-subagent-oar/src/index.ts b/packages/experimental/tool-subagent-oar/src/index.ts index 137e9fb423..bb460f22f0 100644 --- a/packages/experimental/tool-subagent-oar/src/index.ts +++ b/packages/experimental/tool-subagent-oar/src/index.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-subagent-names' @@ -37,7 +38,7 @@ export function apply(ctx: Context, config: Config): void { name: toolName, description: 'Delegate a self-contained task to a configured external agent. Choose its runtime for each call. ' + 'The child works in its own context and does not see this conversation. ' - + 'This tool returns a full agent path; a later completion notice includes its final answer. ' + + 'This tool returns the child id and full agent path; a later completion notice includes its final answer. ' + 'The child does not accept follow-up messages. Start independent tasks together and continue useful work while they run. ' + 'When only waiting for their results remains, end your turn instead of polling.', parameters: { @@ -74,10 +75,11 @@ export function apply(ctx: Context, config: Config): void { additionalProperties: false, properties: { kind: { type: 'string', required: true, const: 'activation' }, + subagentId: { type: 'string', required: true }, name: { type: 'string', required: true }, }, }, - render: (_args, value) => [{ type: 'text', text: `started subagent ${value.name}` }], + render: (_args, value) => [{ type: 'text', text: `started subagent ${value.subagentId}\nname: ${value.name}` }], }, /* jscpd:ignore-end */ isConcurrencySafe: () => true, @@ -90,17 +92,31 @@ export function apply(ctx: Context, config: Config): void { if (provider !== undefined && (provider.prepareContinuable !== undefined || provider.inheritsParentContext)) { throw new Error('subagent_oar requires a one-shot provider with independent conversation context') } - const activation = await ctx.subagentNames.startActivation({ - ...args.name === undefined ? {} : { name: args.name }, - provider: providerName, - label: args.description, - parent, - prompt: [{ type: 'text', text: args.prompt }], - ...args.cwd === undefined ? {} : { cwd: args.cwd }, - signal: exec.signal, - delivery: 'parent', - }) - return { kind: 'activation' as const, name: activation.name } + const names = ctx.subagentNames + const reservation = names.reserve(parent, args.name) + try { + const activation = await ctx.subagents.startActivation({ + provider: providerName, + label: args.description, + parent, + prompt: [{ type: 'text', text: args.prompt }], + ...args.cwd === undefined ? {} : { cwd: args.cwd }, + signal: exec.signal, + delivery: 'parent', + }) + try { + names.bind(parent, activation.childId, reservation.name) + } catch (cause: unknown) { + throw new HarnessError( + `Subagent ${activation.childId} started, but its name ${reservation.name} could not be recorded. ` + + 'Its execution is still managed by the subagent service. Check this child before retrying.', + 'SUBAGENT_NAME_BIND_FAILED', { cause }, + ) + } + return { kind: 'activation' as const, subagentId: activation.childId, name: reservation.name } + } finally { + reservation.dispose() + } }, presentCall: args => ({ card: 'generic', title: args.description, rawInput: args }), })) diff --git a/packages/experimental/tool-subagent-oar/tests/tool-subagent-oar.spec.ts b/packages/experimental/tool-subagent-oar/tests/tool-subagent-oar.spec.ts index b73675406f..9820c285bf 100644 --- a/packages/experimental/tool-subagent-oar/tests/tool-subagent-oar.spec.ts +++ b/packages/experimental/tool-subagent-oar/tests/tool-subagent-oar.spec.ts @@ -8,8 +8,8 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentRuntime, { NO_START_CAPABILITIES } from '@deepseek-ai/dsh-subagent' import SubagentNames from '@deepseek-ai/dsh-subagent-names' -import type { ResolvedSubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import { expect, it } from 'vitest' +import type { ResolvedSubagentStartRequest, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' +import { expect, it, vi } from 'vitest' import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts' import * as externalTool from '../src/index.ts' @@ -20,14 +20,15 @@ async function setup() { await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentNames) - const calls: { provider: string; request: ResolvedSubagentStartRequest }[] = [] + const calls: { id: SessionId; provider: string; request: ResolvedSubagentStartRequest }[] = [] for (const name of ['codex-provider', 'claude-provider', 'future-provider']) { ctx.subagents.registerProvider({ name, capabilities: NO_START_CAPABILITIES, inheritsParentContext: false, start: async (request) => { - calls.push({ provider: name, request }) + const id = SessionId(randomUUID()) + calls.push({ id, provider: name, request }) return { - id: SessionId(randomUUID()), + id, result: Promise.resolve({ output: [{ type: 'text' as const, text: 'child result' }], stopReason: 'completed' as const }), dispose: () => Promise.resolve(), } @@ -55,15 +56,12 @@ it('selects one configured provider per call and exposes only task inputs', asyn arguments: { runtime, description: `Use ${runtime}`, prompt: 'independent task' }, }) expect(result.isError, JSON.stringify(result.content)).toBe(false) - expect(result.content).toHaveLength(1) - const content = result.content[0] - expect(content?.type).toBe('text') - if (content?.type === 'text') expect(content.text).toBe(`started subagent lead/agent-${index + 1}`) + expect(result.value).toEqual({ kind: 'activation', subagentId: calls[index]?.id, name: `lead/agent-${index + 1}` }) + expect(result.content).toEqual([{ type: 'text', text: `started subagent ${calls[index]?.id}\nname: lead/agent-${index + 1}` }]) } expect(calls.map(call => call.provider)).toEqual(['codex-provider', 'claude-provider', 'future-provider']) expect(calls[0]?.request.prompt).toEqual([ { type: 'text', text: 'independent task' }, - { type: 'text', text: 'Your name is "lead/agent-1". Your parent agent name is "lead".' }, ]) expect(calls[0]?.request.parent).toBe(parent) expect(calls[0]?.request.cwd).toBe(process.cwd()) @@ -142,7 +140,8 @@ it('passes an explicit child directory and presents the task description', async agent: parent, name: 'subagent_oar', arguments: args, callId: ToolCallId(randomUUID()), signal: test.signal, }) expect(result.isError).toBe(false) - expect(result.content).toEqual([{ type: 'text', text: 'started subagent lead/inspector' }]) + expect(result.content).toEqual([{ type: 'text', text: `started subagent ${calls[0]?.id}\nname: lead/inspector` }]) + expect(result.value).toEqual({ kind: 'activation', subagentId: calls[0]?.id, name: 'lead/inspector' }) expect(calls[0]?.request.cwd).toBe(join(process.cwd(), 'packages')) }) @@ -152,3 +151,69 @@ it('supports direct composition with the declared optional defaults', async (tes externalTool.apply(ctx, { providers: { codex: 'codex-provider' } }) expect(ctx.tools.schemas().some(tool => tool.name === 'subagent_oar')).toBe(true) }) + +it('releases the requested name when provider startup fails', async (test) => { + const { ctx, parent } = await setup() + test.onTestFinished(() => ctx.fiber.dispose()) + const childId = SessionId(randomUUID()) + const start = vi.fn<() => Promise>() + .mockRejectedValueOnce(new Error('provider startup failed')) + .mockResolvedValueOnce({ + id: childId, + result: Promise.resolve({ output: [], stopReason: 'completed' }), + dispose: async () => {}, + }) + ctx.subagents.registerProvider({ + name: 'retry-provider', capabilities: NO_START_CAPABILITIES, inheritsParentContext: false, start, + }) + await ctx.plugin(externalTool, { providers: { retry: 'retry-provider' } }) + const args = { name: 'inspector', runtime: 'retry', description: 'task', prompt: 'task' } + const failed = await executeToolFixture(ctx, { + agent: parent, name: 'subagent_oar', arguments: args, callId: ToolCallId(randomUUID()), signal: test.signal, + }) + expect(failed.isError).toBe(true) + expect(failed.error?.message).toContain('provider startup failed') + const retried = await executeToolFixture(ctx, { + agent: parent, name: 'subagent_oar', arguments: args, callId: ToolCallId(randomUUID()), signal: test.signal, + }) + expect(retried.isError, JSON.stringify(retried.content)).toBe(false) + expect(retried.value).toEqual({ kind: 'activation', subagentId: childId, name: 'lead/inspector' }) + expect(start).toHaveBeenCalledTimes(2) +}) + +it('reports the accepted child id without stopping it when name binding fails', async (test) => { + const { ctx, parent } = await setup() + const childId = SessionId(randomUUID()) + const childResult = Promise.withResolvers() + let childRequest: ResolvedSubagentStartRequest | undefined + const dispose = vi.fn(async () => { childResult.resolve({ output: [], stopReason: 'aborted' }) }) + const bind = vi.spyOn(ctx.subagentNames, 'bind').mockImplementationOnce(() => { throw new Error('session append failed') }) + test.onTestFinished(async () => { + bind.mockRestore() + childResult.resolve({ output: [], stopReason: 'completed' }) + await ctx.fiber.dispose() + }) + ctx.subagents.registerProvider({ + name: 'pending-provider', capabilities: NO_START_CAPABILITIES, inheritsParentContext: false, + start: async (request) => { + childRequest = request + return { id: childId, result: childResult.promise, dispose } + }, + }) + await ctx.plugin(externalTool, { providers: { pending: 'pending-provider' } }) + const result = await executeToolFixture(ctx, { + agent: parent, name: 'subagent_oar', callId: ToolCallId(randomUUID()), signal: test.signal, + arguments: { name: 'inspector', runtime: 'pending', description: 'task', prompt: 'task' }, + }) + expect(result.isError).toBe(true) + expect(result.error?.info?.code).toBe('SUBAGENT_NAME_BIND_FAILED') + expect(result.error?.message).toContain(`Subagent ${childId} started, but its name lead/inspector could not be recorded.`) + expect(result.error?.message).toContain('Check this child before retrying.') + expect(dispose).not.toHaveBeenCalled() + expect(childRequest?.signal.aborted).toBe(false) + const reservation = ctx.subagentNames.reserve(parent, 'inspector') + expect(reservation.name).toBe('lead/inspector') + reservation.dispose() + childResult.resolve({ output: [{ type: 'text', text: 'child finished' }], stopReason: 'completed' }) + await expect.poll(() => dispose.mock.calls.length).toBe(1) +}) diff --git a/packages/experimental/tool-subagent-oar/tsconfig.json b/packages/experimental/tool-subagent-oar/tsconfig.json index 06cc8c05d3..2aec884c01 100644 --- a/packages/experimental/tool-subagent-oar/tsconfig.json +++ b/packages/experimental/tool-subagent-oar/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/tools" }, + { + "path": "../../llm/llm" + }, { "path": "../../subagent/subagent" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9930354419..4e3630633a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3147,33 +3147,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagentNames', - summary: 'Catalog-backed names that leave subagent execution and authorization with their existing owner.', - description: 'Catalog-backed names that leave subagent execution and authorization with their existing owner.', + summary: 'Root-Session aliases shared by named creation, discovery, and control tools.', + description: 'Root-Session aliases shared by named creation, discovery, and control tools.', methods: [ { - signature: 'async startActivation(spec: NamedSubagentActivationSpec): Promise', - description: 'Start a named child, reserving its sibling name until startup settles. Parent delivery retains catalog membership. Local names precede catalog publication; external names follow accepted startup.', - parameters: [{ name: 'spec', description: 'ordinary activation options and an optional lower-case sibling name.' }], - returns: 'the existing activation receipt plus its full name.', - throws: ['SubagentNameError for invalid or duplicate names. An external binding failure includes childId and awaits disposal without undoing completed work.'], + signature: 'reserve(parent: Agent, requested?: string): SubagentNameReservation', + description: 'Reserve a complete name without starting an agent or writing a Session event.', + parameters: [{ name: 'parent', description: 'exact live caller whose name prefixes the child name.' }, { name: 'requested', description: 'optional sibling segment; omission selects the first free agent-N.' }], + returns: 'a reservation the caller must dispose after startup and binding.', }, { - signature: 'async nameOf(agent: Agent, signal?: AbortSignal): Promise', - description: 'Read the full name of an exact live Agent without restoring its ancestors.', - parameters: [{ name: 'agent', description: 'live root or managed child whose name is requested.' }, { name: 'signal', description: 'cancellation during parent catalog reads.' }], - returns: 'lead, named path segments, or legacy id segments for unnamed children.', + signature: 'bind(parent: Agent, childId: SessionId, name: string): void', + description: 'Record an accepted child\'s full name in the caller\'s root Session. Binding failure does not cancel or dispose the child; callers report its id.', + parameters: [{ name: 'parent', description: 'exact live caller whose root owns the alias.' }, { name: 'childId', description: 'identity returned by the subagent service.' }, { name: 'name', description: 'complete name returned by reserve.' }], }, { - signature: 'async resolveName(caller: Agent, name: string, signal?: AbortSignal): Promise', - description: 'Translate a canonical path or a tree member\'s exact id without granting authority.', - parameters: [{ name: 'caller', description: 'exact live Agent selecting the subagent tree.' }, { name: 'name', description: 'lead, a lead-prefixed path, or an existing member\'s exact Session id.' }, { name: 'signal', description: 'cancellation during catalog reads.' }], - returns: 'the identity for the subagent operation to authorize.', + signature: 'nameOf(caller: Agent, targetId: SessionId = caller.id): string', + description: 'Read a bound name from the live root\'s projection without loading ancestors.', + parameters: [{ name: 'caller', description: 'exact live caller selecting the tree.' }, { name: 'targetId', description: 'requested identity; omission selects the caller.' }], + returns: 'lead for the root, a bound full name, or the unchanged id.', }, { - signature: 'async listDescendants(rootId: SessionId, signal?: AbortSignal): Promise<(SubagentDescendantListEntry & { readonly path: string })[]>', - description: 'Attach relative names to the existing catalog traversal and its branch diagnostics.', - parameters: [{ name: 'rootId', description: 'Session whose catalog starts discovery.' }, { name: 'signal', description: 'cancellation while reading descendant and name projections.' }], - returns: 'existing descendant rows with paths relative to rootId.', + signature: 'resolveName(caller: Agent, name: string): SessionId', + description: 'Resolve an alias or pass through an id for the subagent service to authorize.', + parameters: [{ name: 'caller', description: 'exact live caller selecting the root mapping.' }, { name: 'name', description: 'lead, a bound full name, or a raw Session id.' }], + returns: 'the target identity; an unknown lead/... name throws.', }, ], }, @@ -6567,10 +6565,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModTier', declaration: 'export type ModTier = \'prepend\' | \'user\' | \'append\' | \'builtin\' | \'core\';', }, - { - name: 'NamedSubagentActivationSpec', - declaration: 'export type NamedSubagentActivationSpec = Omit & {\n readonly delivery: \'parent\';\n readonly name?: string;\n};', - }, { name: 'NativeFileApplication', declaration: 'export interface NativeFileApplication {\n readonly id: string;\n readonly name: string;\n readonly default: boolean;\n readonly icon: string | null;\n}', @@ -8211,6 +8205,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentListEntry', declaration: 'export type SubagentListEntry = SubagentCatalogRow & {\n readonly kind: \'child\';\n readonly hasChildren: boolean;\n} | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', }, + { + name: 'SubagentNameReservation', + declaration: 'export interface SubagentNameReservation {\n readonly name: string;\n dispose(): void;\n}', + }, { name: 'SubagentPromptReceipt', declaration: 'export interface SubagentPromptReceipt {\n readonly messageId: MessageId;\n}', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 65bbfa00de..8cbf2d2357 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -462,7 +462,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-subagent-codex` | yes | One-shot Codex subagent provider over the official app-server protocol | | `@deepseek-ai/dsh-subagent-dsh-sdk` | yes | Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client | | `@deepseek-ai/dsh-subagent-fork-in-process` | yes | In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log | -| `@deepseek-ai/dsh-subagent-names` | no | Optional catalog-backed names for subagent tools | +| `@deepseek-ai/dsh-subagent-names` | no | Durable name mappings for subagent tools | | `@deepseek-ai/dsh-subagent-spawn-in-process` | yes | In-process spawn subagent backend: runs a fresh child agent on ctx.agents | | `@deepseek-ai/dsh-tool-subagent` | yes | Model-facing subagent delegation tool over the ctx.subagents seam | | `@deepseek-ai/dsh-tool-subagent-control` | yes | Shared messaging, interruption, and child discovery tools for subagents | diff --git a/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts index 642dbe20c9..cf63acbbbe 100644 --- a/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts @@ -43,7 +43,7 @@ function settlementText(events: SessionEvent[]): string[] { const notice = notices[0]! if (notice.type !== 'user/message' || notice.data.source.kind !== 'subagent-settled') throw new Error('missing completion notice') expect(results[0]!.data.message.content).toEqual([ - { type: 'text', text: 'started subagent lead/cwd-probe' }, + { type: 'text', text: `started subagent ${notice.data.source.senderSessionId}\nname: lead/cwd-probe` }, ]) return notice.data.content.slice(1).filter(block => block.type === 'text').map(block => block.text) } diff --git a/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts b/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts index 82a28e1ae7..9a10d66ed2 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/loader-composition.e2e.ts @@ -47,7 +47,7 @@ function settlementText(events: SessionEvent[]): string[] { const notice = notices[0]! if (notice.type !== 'user/message' || notice.data.source.kind !== 'subagent-settled') throw new Error('missing completion notice') expect(results[0]!.data.message.content).toEqual([ - { type: 'text', text: 'started subagent lead/route-probe' }, + { type: 'text', text: `started subagent ${notice.data.source.senderSessionId}\nname: lead/route-probe` }, ]) return notice.data.content.slice(1).filter(block => block.type === 'text').map(block => block.text) } diff --git a/packages/subagent/subagent-names/README.i18n.yaml b/packages/subagent/subagent-names/README.i18n.yaml index 694670d4f3..4a020d64f8 100644 --- a/packages/subagent/subagent-names/README.i18n.yaml +++ b/packages/subagent/subagent-names/README.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-names/README.md /: - en: ac134a941d5a4791 - zh: 2e78de95e7019137 + en: 26564f09306cf08f + zh: b8933154b4911a0c /deepseek-ai-dsh-subagent-names: en: 622989d6dcd8611a zh: 6f0a3beb8e665332 /deepseek-ai-dsh-subagent-names/summary: - en: dce79dec57405de4 - zh: 322c420f5779baaa + en: 1af1d0f9ab2e446e + zh: 4846168336979ca6 /deepseek-ai-dsh-subagent-names/table-of-contents: en: d152484eb41ac6b4 zh: c614dc45fe9577a5 @@ -18,41 +18,29 @@ en: 15e2ab025baa3f2e zh: eab7ed264c78a4e4 /deepseek-ai-dsh-subagent-names/use-this-package/minimal-configuration: - en: 12034dd48a7e0c1b - zh: a84690d6356ffbd3 + en: 46eb3758e96fa2a9 + zh: d85a69812bb4ed77 /deepseek-ai-dsh-subagent-names/use-this-package/choosing-and-finding-names: - en: 3f36d041164a1e04 - zh: 97418063aec0a08c -/deepseek-ai-dsh-subagent-names/use-this-package/creation-and-failure: - en: f5ee74db9a397c18 - zh: 3c9acad39ef8127d + en: d7f5ce2da602deb1 + zh: 9862710f0cd113b9 +/deepseek-ai-dsh-subagent-names/use-this-package/binding-after-startup: + en: 9c0853a5c5b3f776 + zh: 8d63108d8fb65f6e /deepseek-ai-dsh-subagent-names/understand-the-implementation: - en: 4b0f042e1512b764 - zh: 65329202d8ce93b3 + en: e7ead7723e3e5d29 + zh: 30a1b369fe8df0a5 /deepseek-ai-dsh-subagent-names/further-exploration: - en: 8509edc3d9146efa - zh: 09a6e3ebd1a75011 + en: ed0fb57dc13736e6 + zh: acd0cd0756510e65 /deepseek-ai-dsh-subagent-names/model-experience: - en: 215e7ba838619b7b - zh: a311da3843709f90 -/deepseek-ai-dsh-subagent-names/model-experience/initial-child-identity: - en: 22f7737ddb8bb24b - zh: 497b7190cf316a0d -/deepseek-ai-dsh-subagent-names/model-experience/initial-child-identity/what-the-model-sees: - en: d05d767ea412d8bd - zh: 031080dcce3211d6 -/deepseek-ai-dsh-subagent-names/model-experience/initial-child-identity/what-the-model-sees/identity-message: - en: d032a01e1b229d26 - zh: 05dd1a8dc54e9109 -/deepseek-ai-dsh-subagent-names/model-experience/initial-child-identity/token-effect: - en: 63fa3c1fab95b75a - zh: 9805b73493dddb66 -/deepseek-ai-dsh-subagent-names/model-experience/initial-child-identity/kv-cache-effect: - en: 248a19e7e4b73b1d - zh: 6d360af936b15bb5 + en: 40f9481ebd3e077d + zh: 118c16ec3fed9652 +/deepseek-ai-dsh-subagent-names/model-experience/kv-cache-effect: + en: f1456be695a842d8 + zh: ba3a1608efd6100b /deepseek-ai-dsh-subagent-names/known-limitations-and-deferred-work: - en: 9b06e33292c3ff6d - zh: 654cd7f7b49bfc2f + en: 81988fdd0823cffa + zh: 96bacb5621c1e874 /deepseek-ai-dsh-subagent-names/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 8ec3e73528ddb1a6 diff --git a/packages/subagent/subagent-names/README.md b/packages/subagent/subagent-names/README.md index 8f3a6718c9..d9b2226218 100644 --- a/packages/subagent/subagent-names/README.md +++ b/packages/subagent/subagent-names/README.md @@ -1,5 +1,5 @@ --- -description: "Create subagents with durable sibling names and resolve their tree paths for delegation tools without changing Session identities." +description: "Bind readable subagent names to Session ids and recover the mappings from the root Session." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Give delegated agents readable names such as `lead/research` and `lead/research/checker`. Names survive Session reloads, and lookup can read parents that are no longer live. Choose this package for named delegation tools while retaining Session ids for execution and authorization. Names identify existing catalog children; a failed creation does not permanently occupy its requested name. +Address delegated agents by readable names such as `lead/research` and `lead/research/checker`. Bound names survive root Session reloads and keep the same spelling after agents finish. Choose this package when delegation tools need names while preserving Session ids for execution and authorization. Agents without a bound name remain addressable by id. ## Table of Contents @@ -29,27 +29,25 @@ Mount this plugin with the naming-aware [delegation tool](../tool-subagent/READM ### Minimal configuration -Add this row to a composition providing `agents`, `subagents`, and `sessionProjections`: +Add this row to a composition providing `subagents` and `sessionProjections`: ```yaml - name: '@deepseek-ai/dsh-subagent-names' ``` -This plugin has no configuration fields. It exposes `ctx.subagentNames`; the existing subagent service continues to own providers, activations, messages, and authorization. Mount `sessionQuery` for parent reads used by name lookup and discovery; starting a root's child and reading the root's name do not require it. +This plugin has no configuration fields. It exposes `ctx.subagentNames`; the subagent service owns execution, messages, discovery, and authorization. ### Choosing and finding names -A requested sibling name starts with a lower-case letter and contains only lower-case letters, digits, underscores, and hyphens. Omission selects the first available `agent-N` name, starting at `agent-1`. Names are immutable and unique under their direct parent, including concurrent starts. A committed child keeps its name after execution settles. +A requested name segment starts with a lower-case letter and contains only lower-case letters, digits, underscores, and hyphens. Omission selects the first available `agent-N` under the parent's name, starting at `agent-1`. Reservations prevent concurrent starts from choosing the same full name. A bound name remains occupied after execution settles. -The live tree root is `lead`; child paths append one segment per parent. An unnamed catalog child uses `legacy:` as its segment. Lookup accepts a full `lead/...` path, `lead`, or a tree member's exact Session id, including ids in existing runtime notices. It resolves identity only; the subsequent subagent operation still authorizes the caller. +The root is `lead`. `nameOf` returns a bound full name, or the original Session id when no binding exists. `resolveName` accepts `lead` and bound full names; it rejects an unknown `lead/...` name and passes other ids through. The subsequent subagent operation still authorizes the caller. -### Creation and failure +### Binding after startup -Call `ctx.subagentNames.startActivation` with `delivery: 'parent'` so the existing catalog retains the child identity. For caller-owned delivery, use `ctx.subagents.startActivation`; that operation retains its id-based behavior. +Call `reserve(parent, requested?)`, start the child through `ctx.subagents.startActivation`, then call `bind(parent, childId, reservation.name)` with the successful receipt. Release the reservation with `dispose()` in `finally`, whether startup and binding succeed or fail. -Local creation records a candidate name before starting the child. The name becomes discoverable only when the existing parent catalog records that child. A candidate left by failed creation or a crash neither resolves nor occupies the name while its child is absent from the catalog. Once the catalog contains the child, a later startup failure does not free its name. - -External providers allocate their own ids, so their names are recorded after startup returns. If that recording fails, the operation attempts and awaits disposal, then reports the started child id. Completed external work cannot be undone; if disposal also fails, execution may still be active. Check that child before retrying. +Binding records the full name in the root Session. If binding fails after startup succeeds, the caller must report the already-started child id; the child remains managed by the subagent service. A crash before binding can likewise leave an unnamed child that remains addressable by id. ----- @@ -59,14 +57,14 @@ External providers allocate their own ids, so their names are recorded after sta
Implementation internals — click to expand -The plugin owns parent-local name records and joins them with the existing subagent catalog. Its projection excludes inherited fork records. Pending sibling reservations belong to the plugin instance; unloading cancels and awaits in-flight named starts before releasing their state. +Each root Session stores its own name-to-id bindings. A projection recovers those bindings and excludes inherited fork records. Lookups read this root mapping without observing ancestor Sessions or joining child catalogs. Callers own transient reservations and release them after their operation. -Name reads traverse creation ancestry through Session observations without restoring parent agents. Discovery adds relative paths to the subagent service's existing descendant rows. Missing projections and inconsistent catalog/name records fail explicitly instead of supplying an unverified alias. +The mapping retains one binding per named child: storage is O(n), a lookup or binding is O(n), and replaying n distinct bindings is O(n²) in the worst case because each event searches the binding array. | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Named startup, reservation lifetime, path lookup, and discovery | -| [`src/projection.ts`](src/projection.ts) | Durable candidate records and replay validation | +| [`src/index.ts`](src/index.ts) | Name reservations, binding, and lookup | +| [`src/projection.ts`](src/projection.ts) | Durable bindings and replay validation |
@@ -75,48 +73,34 @@ Name reads traverse creation ancestry through Session observations without resto ## Further Exploration -These pages own the execution behavior and model-facing consumers that use names. +These pages own execution and the model-facing tools that use names. - [Subagent service](../subagent/README.md) — activation ownership, catalogs, and authorization. - [Delegation tool](../tool-subagent/README.md) — named creation and start acknowledgements. - [Control tools](../tool-subagent-control/README.md) — discovery, messaging, and interruption. -- [Session projections](../../session/session-projection/README.md) — incremental recovery and observation. +- [Session projections](../../session/session-projection/README.md) — incremental recovery. ----- ## Model Experience -### Initial child identity - -#### What the model sees - -Named startup appends the child's full name and its parent's full name to the task prompt. Local children receive this text in their durable initial user message; external providers receive it in the delegated prompt. For a child named `research` under the root: - -##### Identity message - -```markdown -Your name is "lead/research". Your parent agent name is "lead". -``` - -#### Token effect - -One short identity sentence is added to each named child's initial prompt; longer ancestry adds path text. This plugin registers no tool schema or system-prompt section. +Indirectly, through the [delegation tool](../tool-subagent/README.md) and [control tools](../tool-subagent-control/README.md), which render names alongside Session ids and accept names as addresses. #### KV Cache effect -The identity joins the new task prompt after any inherited history. Existing history remains unchanged; each child receives its own identity text. +This plugin adds no prompt text or tool schema. Its consumers own any model-visible name text and its cache effect. ## Known Limitations and Deferred Work -Names supplement the existing execution identities with these limits: +Name bindings have these limits: -- **Direct subagent callers remain unnamed** — starts that bypass `ctx.subagentNames` use the `legacy:` path representation. -- **External name binding follows execution startup** — a recording failure can occur after external work begins or completes. -- **Runtime notices retain Session ids** — names do not rewrite messages or settlement notices owned by the subagent service. -- **Names cannot be renamed or reused for cataloged children** — settling or disposing an activation does not remove its durable catalog membership. +- **Names are available after binding** — the child's first request may precede its binding; this plugin adds no initial identity message. +- **Names are immutable** — bound names and ids cannot be reassigned or reused after an activation ends. +- **Paths capture the parent's name at reservation time** — an unnamed parent uses `lead/legacy:` as its prefix; a later parent binding does not rewrite the child's stored path. +- **Naming is explicit** — callers that do not bind a name continue to use Session ids. ### Dev Note diff --git a/packages/subagent/subagent-names/README.zh.md b/packages/subagent/subagent-names/README.zh.md index c5cb5bd6e0..5320559861 100644 --- a/packages/subagent/subagent-names/README.zh.md +++ b/packages/subagent/subagent-names/README.zh.md @@ -1,5 +1,5 @@ --- -description: "为 subagent 创建持久的同级名称,并为委派工具解析其树路径,同时保留 Session 身份。" +description: "将可读的 subagent 名称绑定到 Session id,并从根 Session 恢复映射。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -为委派的 agent(智能体)赋予 `lead/research` 和 `lead/research/checker` 这样的可读名称。名称在 Session 重新加载后仍然保留,查询也能读取已不在运行中的父 agent。需要命名委派工具,同时保留 Session id 用于执行和授权时,可选择此包。名称标识目录中已有的子 agent;创建失败不会永久占用所请求的名称。 +通过 `lead/research` 和 `lead/research/checker` 这样的可读名称寻址委派的 agent(智能体)。绑定名称在根 Session 重新加载后仍然保留,agent 执行结束后也保持原名称。委派工具需要名称,同时仍以 Session id 执行操作和检查授权时,可选择此包。未绑定名称的 agent 仍可通过 id 寻址。 ## 目录 @@ -29,27 +29,25 @@ kind: "package-reference" ### 最小配置 -在提供 `agents`、`subagents` 和 `sessionProjections` 的组合中添加此配置项: +在提供 `subagents` 和 `sessionProjections` 的组合中添加此配置项: ```yaml - name: '@deepseek-ai/dsh-subagent-names' ``` -此插件没有配置字段。它提供 `ctx.subagentNames`;现有 subagent 服务继续负责提供方、激活、消息和授权。名称查询和发现所需的父级读取要求挂载 `sessionQuery`;为根启动子 agent 以及读取根名称不需要它。 +此插件没有配置字段。它提供 `ctx.subagentNames`;subagent 服务负责执行、消息、发现和授权。 ### 选择和查找名称 -请求的同级名称以小写字母开头,只能包含小写字母、数字、下划线和连字符。省略名称时,从 `agent-1` 开始选择第一个可用的 `agent-N` 名称。名称不可变,且在直属父 agent 下唯一,并发启动也遵守此规则。已提交的子 agent 在执行结算后仍保留其名称。 +请求的名称段以小写字母开头,只能包含小写字母、数字、下划线和连字符。省略时,从 `agent-1` 开始选择父名称下第一个可用的 `agent-N`。预留可防止并发启动选择相同的完整名称。执行结算后,绑定名称仍被占用。 -运行中树的根名称为 `lead`;子路径按父级逐段追加。目录中未命名的子 agent 使用 `legacy:` 作为路径段。查询接受完整的 `lead/...` 路径、`lead`,或树成员的确切 Session id,包括现有运行时通知中的 id。查询只解析身份;后续 subagent 操作仍会对调用方进行授权检查。 +根名称为 `lead`。`nameOf` 返回已绑定的完整名称;没有绑定时,返回原 Session id。`resolveName` 接受 `lead` 和已绑定的完整名称;它拒绝未知的 `lead/...` 名称,并透传其他 id。后续 subagent 操作仍会对调用方进行授权检查。 -### 创建与失败 +### 启动后绑定 -调用 `ctx.subagentNames.startActivation` 时使用 `delivery: 'parent'`,让现有目录保留子 agent 身份。需要由调用方接收结果时,使用 `ctx.subagents.startActivation`;该操作保留基于 id 的行为。 +调用 `reserve(parent, requested?)`,通过 `ctx.subagents.startActivation` 启动子 agent,再根据成功回执调用 `bind(parent, childId, reservation.name)`。无论启动和绑定成功还是失败,都要在 `finally` 中调用 `dispose()` 释放预留。 -本地创建会在启动子 agent 前记录候选名称。只有现有父目录记录该子 agent 后,名称才可被发现。创建失败或崩溃遗留的候选记录,只要其子 agent 不在目录中,就既不可解析,也不占用名称。目录包含子 agent 后,即使启动随后失败,也不会释放其名称。 - -外部提供方分配自己的 id,因此在启动返回后才记录名称。如果记录失败,操作会尝试并等待 dispose(资源释放),然后报告已启动的子 agent id。已完成的外部工作无法撤销;若 dispose 也失败,执行可能仍在继续。重试前请检查该子 agent。 +绑定将完整名称记录到根 Session。若启动成功后绑定失败,调用方必须报告已启动的子 agent id;该子 agent 仍由 subagent 服务管理。绑定前崩溃也可能留下未命名的子 agent,它仍可通过 id 寻址。 ----- @@ -59,14 +57,14 @@ kind: "package-reference"
实现内部细节 — 点击展开 -插件拥有父级本地的名称记录,并将其与现有 subagent 目录关联。其投影排除 fork 继承的记录。待提交的同级名称预留属于插件实例;卸载时会先取消并等待进行中的命名启动,再释放其状态。 +每个根 Session 保存自己的名称到 id 绑定。投影恢复这些绑定,并排除 fork 继承的记录。查询读取根映射,无需观察祖先 Session 或关联子 agent 目录。调用方拥有临时预留,并在操作后释放它们。 -名称读取通过 Session 观察沿创建祖先关系遍历,无需恢复父 agent。发现操作为 subagent 服务已有的后代条目附加相对路径。投影缺失或目录与名称记录不一致时会明确失败,不会提供未经确认的别名。 +映射为每个具名子 agent 保留一条绑定:空间为 O(n),单次查询或绑定为 O(n);由于每个事件都会搜索绑定数组,回放 n 条不同绑定的最坏时间复杂度为 O(n²)。 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 命名启动、预留生命周期、路径查询与发现 | -| [`src/projection.ts`](src/projection.ts) | 持久候选记录与回放校验 | +| [`src/index.ts`](src/index.ts) | 名称预留、绑定与查询 | +| [`src/projection.ts`](src/projection.ts) | 持久绑定与回放校验 |
@@ -75,48 +73,34 @@ kind: "package-reference" ## 进一步探索 -以下页面负责执行行为以及使用名称的模型侧消费方。 +以下页面负责执行行为以及使用名称的模型侧工具。 - [Subagent 服务](../subagent/README.zh.md) — 激活所有权、目录与授权。 - [委派工具](../tool-subagent/README.zh.md) — 命名创建与启动确认。 - [控制工具](../tool-subagent-control/README.zh.md) — 发现、消息与中断。 -- [Session 投影](../../session/session-projection/README.zh.md) — 增量恢复与观察。 +- [Session 投影](../../session/session-projection/README.zh.md) — 增量恢复。 ----- ## 模型体验 -### 子 agent 初始身份 - -#### 模型看到什么 - -命名启动会在任务提示词后加入子 agent 及其父 agent 的完整名称。本地子 agent 在持久的初始用户消息中接收此文本;外部提供方在委派提示词中接收它。根下名为 `research` 的子 agent 会收到: - -##### 身份消息 - -```markdown -Your name is "lead/research". Your parent agent name is "lead". -``` - -#### Token 影响 - -每个命名子 agent 的初始提示词增加一条简短的身份句;祖先路径越长,路径文本越多。此插件不注册工具 schema 或系统提示词段落。 +通过[委派工具](../tool-subagent/README.zh.md)和[控制工具](../tool-subagent-control/README.zh.md)间接影响模型;这些工具在 Session id 旁展示名称,并接受名称作为地址。 #### KV Cache 影响 -身份文本作为新任务提示词的一部分,位于继承历史之后。已有历史保持不变;每个子 agent 接收各自的身份文本。 +此插件不添加提示词文本或工具 schema。消费方负责模型可见的名称文本及其缓存影响。 ## 已知限制与待办工作 -名称在现有执行身份之外提供补充,并存在以下限制: +名称绑定存在以下限制: -- **直接调用 subagent 服务的创建仍未命名** — 绕过 `ctx.subagentNames` 的启动使用 `legacy:` 路径表示。 -- **外部名称绑定发生在执行启动之后** — 记录失败时,外部工作可能已经开始或完成。 -- **运行时通知保留 Session id** — 名称不会改写由 subagent 服务负责的消息或结算通知。 -- **目录中的子 agent 不可改名或复用名称** — 激活结算或 dispose 不会移除其持久目录成员记录。 +- **绑定后才可查询名称** — 子 agent 的首个请求可能早于绑定;此插件不添加初始身份消息。 +- **名称不可变** — 激活结束后,绑定的名称和 id 也不能重新分配或复用。 +- **路径采用预留时的父名称** — 未命名的父 agent 使用 `lead/legacy:` 作为前缀;后续父级绑定不会改写子 agent 已存储的路径。 +- **命名需要显式绑定** — 不绑定名称的调用方继续使用 Session id。 ### 开发备注 diff --git a/packages/subagent/subagent-names/package.json b/packages/subagent/subagent-names/package.json index b23ac25022..ef01fb5225 100644 --- a/packages/subagent/subagent-names/package.json +++ b/packages/subagent/subagent-names/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-subagent-names", - "description": "Optional catalog-backed names for subagent tools", + "description": "Durable name mappings for subagent tools", "version": "0.2.1-rc.3", "publishConfig": { "access": "public" @@ -35,7 +35,6 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*" }, "devDependencies": { @@ -48,14 +47,8 @@ "@deepseek-ai/dsh-session-persistence": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" - }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-session-query": { - "optional": true - } } } diff --git a/packages/subagent/subagent-names/src/index.ts b/packages/subagent/subagent-names/src/index.ts index feeefea9db..39b56f9099 100644 --- a/packages/subagent/subagent-names/src/index.ts +++ b/packages/subagent/subagent-names/src/index.ts @@ -1,18 +1,13 @@ -/** Optional names for tool-owned subagent creation and discovery. @module */ +/** Durable aliases for subagent tools; execution remains owned by subagents. @module */ -import { randomUUID } from 'node:crypto' import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import type { ProjectionHandle, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' -import type {} from '@deepseek-ai/dsh-session-query' -import type { - SubagentActivation, SubagentActivationSpec, SubagentCatalogEntry, SubagentDescendantListEntry, -} from '@deepseek-ai/dsh-subagent' -import { nameSchema, subagentNamesProjection } from './projection.ts' -import type { SubagentNameBinding, SubagentNamesState } from './projection.ts' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-subagent' +import { fullNameSchema, nameSchema, subagentNamesProjection } from './projection.ts' +import type { SubagentNamesState } from './projection.ts' export type { SubagentNameBinding } from './projection.ts' @@ -22,302 +17,117 @@ declare module '@deepseek-ai/cordis' { } } -/** Named children use parent delivery so the existing catalog retains their identities. */ -export type NamedSubagentActivationSpec = Omit & { - readonly delivery: 'parent' - readonly name?: string -} +/** Stable categories for alias validation and lookup failures. */ +export type SubagentNameErrorCode = 'INVALID_AGENT_NAME' | 'DUPLICATE_AGENT_NAME' | 'AGENT_NAME_NOT_FOUND' -/** Stable categories for name validation, discovery, and accepted-start binding failures. */ -export type SubagentNameErrorCode = - | 'INVALID_AGENT_NAME' - | 'DUPLICATE_AGENT_NAME' - | 'AGENT_NAME_NOT_FOUND' - | 'CORRUPT_AGENT_NAMES' - | 'AGENT_NAMES_UNAVAILABLE' - | 'DUPLICATE_CHILD' - | 'SUBAGENT_NAME_BIND_FAILED' - -/** A name operation failed; an external binding failure identifies the already-started child. */ +/** An alias could not be reserved, bound, or resolved. */ export class SubagentNameError extends HarnessError { - constructor(message: string, code: SubagentNameErrorCode, options?: ErrorOptions, readonly childId?: SessionId) { - super(message, code, options) + constructor(message: string, code: SubagentNameErrorCode) { + super(message, code) this.name = 'SubagentNameError' } } -interface ParentNames { - readonly children: readonly SubagentCatalogEntry[] - readonly bindings: readonly SubagentNameBinding[] +/** A caller-owned name reservation; release it after startup and binding settle. */ +export interface SubagentNameReservation { + readonly name: string + /** Release the reservation; repeated disposal is harmless. */ + dispose(): void } -interface Startup { - readonly controller: AbortController - readonly settled: Promise -} - -/** Catalog-backed names that leave subagent execution and authorization with their existing owner. */ +/** Root-Session aliases shared by named creation, discovery, and control tools. */ export class SubagentNames extends Service { - static inject = ['agents', 'subagents', 'sessionProjections'] + static inject = ['subagents', 'sessionProjections'] private projection!: ProjectionHandle private readonly pending = new WeakMap>() - private readonly initializing = new Map() - private readonly startups = new Set() - private closing = false constructor(ctx: Context) { super(ctx, 'subagentNames') } protected async [Service.init](): Promise { - const ctx = this.ctx - const registration = ctx.sessionProjections.register(subagentNamesProjection) - const drain = async (): Promise => { - this.closing = true - for (const startup of this.startups) startup.controller.abort(new Error('subagent naming is unloading')) - await Promise.all([...this.startups].map(startup => startup.settled)) - } - ctx.effect(function* () { - // The registry's documented callable registration is its exact effect disposer. - // A synchronous yield adopts it without awaiting its handle or replacing its identity. - yield registration as typeof registration & (() => Promise) - yield ctx.effect(() => drain, 'subagentNames.startups()') - }, 'subagentNames.lifetime()') - this.projection = await registration + this.projection = await this.ctx.sessionProjections.register(subagentNamesProjection) } /** - * Start a named child, reserving its sibling name until startup settles. - * Parent delivery retains catalog membership. Local names precede catalog publication; - * external names follow accepted startup. - * @param spec - ordinary activation options and an optional lower-case sibling name. - * @returns the existing activation receipt plus its full name. - * @throws SubagentNameError for invalid or duplicate names. An external binding failure - * includes childId and awaits disposal without undoing completed work. + * Reserve a complete name without starting an agent or writing a Session event. + * @param parent - exact live caller whose name prefixes the child name. + * @param requested - optional sibling segment; omission selects the first free agent-N. + * @returns a reservation the caller must dispose after startup and binding. */ - async startActivation(spec: NamedSubagentActivationSpec): Promise { - this.assertOpen() - const controller = new AbortController() - const signal = AbortSignal.any([controller.signal, spec.signal]) - const settled = Promise.withResolvers() - const startup = { controller, settled: settled.promise } - this.startups.add(startup) - try { - return await this.startNamed(spec, signal) - } finally { - this.startups.delete(startup) - settled.resolve() - } - } - - private async startNamed( - spec: NamedSubagentActivationSpec, - signal: AbortSignal, - ): Promise { - const { name: requested, ...request } = spec + reserve(parent: Agent, requested?: string): SubagentNameReservation { if (requested !== undefined && !nameSchema.safeParse(requested).success) { throw new SubagentNameError('agent name must start with a lower-case letter and contain only lower-case letters, digits, underscores, or hyphens', 'INVALID_AGENT_NAME') } - const parent = spec.parent const root = this.ctx.subagents.rootOf(parent) - const parentName = await this.nameOf(parent, signal) - this.assertOpen() - signal.throwIfAborted() - this.ctx.subagents.rootOf(parent) - const provider = this.ctx.subagents.getProvider(spec.provider) - if (provider === undefined) throw new SubagentNameError(`subagent provider "${spec.provider}" is unavailable`, 'AGENT_NAMES_UNAVAILABLE') - const pending = this.pending.get(parent) ?? new Set() - this.pending.set(parent, pending) - const names = this.liveParentNames(parent.session) - const occupied = new Set([...names.children.map(child => this.segment(names, child.id)), ...pending]) + const bindings = this.projection.stateOf(root.session).bindings + const prefix = parent === root ? 'lead' + : bindings.find(binding => binding.childId === parent.id)?.name ?? `lead/legacy:${parent.id}` + const pending = this.pending.get(root) ?? new Set() + this.pending.set(root, pending) + const occupied = new Set([...bindings.map(binding => binding.name), ...pending]) let segment = requested if (segment === undefined) { let ordinal = 1 - while (occupied.has(`agent-${ordinal}`)) ordinal++ + while (occupied.has(`${prefix}/agent-${ordinal}`)) ordinal++ segment = `agent-${ordinal}` } - if (occupied.has(segment)) throw new SubagentNameError(`agent name "${segment}" is already in use under ${parentName}; choose another name`, 'DUPLICATE_AGENT_NAME') - const name = `${parentName}/${segment}` - pending.add(segment) - const childId = provider.prepareContinuable === undefined ? undefined : SessionId(randomUUID()) - try { - if (childId !== undefined) { - parent.session.append('subagent-name/bound', { version: 1, childId, name: segment }) - this.initializing.set(childId, { root, name }) - } - const activation = await this.ctx.subagents.startActivation({ - ...request, - ...childId === undefined ? {} : { childId }, - signal, - prompt: [ - ...request.prompt, - { type: 'text', text: `Your name is ${JSON.stringify(name)}. Your parent agent name is ${JSON.stringify(parentName)}.` }, - ], - }) - if (childId === undefined) { - try { - signal.throwIfAborted() - this.ctx.subagents.rootOf(parent) - if (this.projection.stateOf(parent.session).bindings.some(binding => binding.childId === activation.childId)) { - throw new SubagentNameError(`subagent identity ${activation.childId} already has a name candidate`, 'DUPLICATE_CHILD') - } - parent.session.append('subagent-name/bound', { version: 1, childId: activation.childId, name: segment }) - } catch (error: unknown) { - let cause = error - let disposition = 'Its activation has been disposed; completed work cannot be undone.' - try { - await activation.dispose() - } catch (cleanupError: unknown) { - cause = new AggregateError([error, cleanupError], 'External subagent name binding and disposal failed', { cause: error }) - disposition = 'Its disposal also failed; execution may still be active.' - } - throw new SubagentNameError( - `Subagent ${activation.childId} started but its name ${name} could not be recorded. ${disposition} Check this child before retrying.`, - 'SUBAGENT_NAME_BIND_FAILED', { cause }, activation.childId, - ) - } - } - return { ...activation, name } - } finally { - pending.delete(segment) - if (childId !== undefined) this.initializing.delete(childId) - } + const name = `${prefix}/${segment}` + if (occupied.has(name)) throw new SubagentNameError(`agent name "${name}" is already in use; choose another name`, 'DUPLICATE_AGENT_NAME') + pending.add(name) + let active = true + return { name, dispose: () => { + if (!active) return + active = false + pending.delete(name) + } } } /** - * Read the full name of an exact live Agent without restoring its ancestors. - * @param agent - live root or managed child whose name is requested. - * @param signal - cancellation during parent catalog reads. - * @returns lead, named path segments, or legacy id segments for unnamed children. + * Record an accepted child's full name in the caller's root Session. + * Binding failure does not cancel or dispose the child; callers report its id. + * @param parent - exact live caller whose root owns the alias. + * @param childId - identity returned by the subagent service. + * @param name - complete name returned by reserve. */ - async nameOf(agent: Agent, signal?: AbortSignal): Promise { - this.assertOpen() - signal?.throwIfAborted() - const pending = this.initializing.get(agent.id) - if (pending !== undefined && this.ctx.agents.get(agent.id) === agent - && this.ctx.agents.get(pending.root.id) === pending.root) return pending.name - const root = this.ctx.subagents.rootOf(agent) - if (root === agent) return 'lead' - const segments: string[] = [] - const visited = new Set([agent.id]) - let childId = agent.id - let parentId = agent.session.header.parentSession - while (parentId !== undefined) { - if (visited.has(parentId)) throw new SubagentNameError('agent naming found cyclic creation ancestry', 'CORRUPT_AGENT_NAMES') - visited.add(parentId) - using parent = await this.query().observeSession(parentId, signal === undefined ? {} : { signal }) - signal?.throwIfAborted() - segments.unshift(this.segment(this.observedNames(parent.projections?.values), childId)) - if (parentId === root.id) { - this.ctx.subagents.rootOf(agent) - return ['lead', ...segments].join('/') - } - childId = parentId - parentId = parent.header.parentSession + bind(parent: Agent, childId: SessionId, name: string): void { + if (!fullNameSchema.safeParse(name).success) throw new SubagentNameError('a bound agent name must be a complete lead/... path', 'INVALID_AGENT_NAME') + const root = this.ctx.subagents.rootOf(parent) + const bindings = this.projection.stateOf(root.session).bindings + const existing = bindings.find(binding => binding.childId === childId || binding.name === name) + if (existing !== undefined) { + if (existing.childId === childId && existing.name === name) return + throw new SubagentNameError(`agent name "${name}" or identity ${childId} is already bound`, 'DUPLICATE_AGENT_NAME') } - throw new SubagentNameError('agent is absent from the root creation ancestry', 'AGENT_NAMES_UNAVAILABLE') + root.session.append('subagent-name/bound', { version: 1, childId, name }) } /** - * Translate a canonical path or a tree member's exact id without granting authority. - * @param caller - exact live Agent selecting the subagent tree. - * @param name - lead, a lead-prefixed path, or an existing member's exact Session id. - * @param signal - cancellation during catalog reads. - * @returns the identity for the subagent operation to authorize. + * Read a bound name from the live root's projection without loading ancestors. + * @param caller - exact live caller selecting the tree. + * @param targetId - requested identity; omission selects the caller. + * @returns lead for the root, a bound full name, or the unchanged id. */ - async resolveName(caller: Agent, name: string, signal?: AbortSignal): Promise { - this.assertOpen() - signal?.throwIfAborted() + nameOf(caller: Agent, targetId: SessionId = caller.id): string { const root = this.ctx.subagents.rootOf(caller) - if (name === 'lead' || name === root.id) return root.id - if (!name.startsWith('lead/')) { - const entries = await this.ctx.subagents.listDescendants(root.id, signal) - const entry = entries.find(candidate => candidate.id === name) - if (entry !== undefined) return entry.id - } else { - let parentId = root.id - const segments = name.slice('lead/'.length).split('/') - for (const [index, segment] of segments.entries()) { - const parent = await this.readParentNames(parentId, signal) - const child = parent.children.find(child => this.segment(parent, child.id) === segment) - if (child === undefined) break - if (index === segments.length - 1) return child.id - if (child.mode === 'external') break - parentId = child.id - } - } - throw new SubagentNameError(`agent "${name}" was not found; use list_agents to discover names or ids`, 'AGENT_NAME_NOT_FOUND') + if (targetId === root.id) return 'lead' + return this.projection.stateOf(root.session).bindings.find(binding => binding.childId === targetId)?.name ?? targetId } /** - * Attach relative names to the existing catalog traversal and its branch diagnostics. - * @param rootId - Session whose catalog starts discovery. - * @param signal - cancellation while reading descendant and name projections. - * @returns existing descendant rows with paths relative to rootId. + * Resolve an alias or pass through an id for the subagent service to authorize. + * @param caller - exact live caller selecting the root mapping. + * @param name - lead, a bound full name, or a raw Session id. + * @returns the target identity; an unknown lead/... name throws. */ - async listDescendants(rootId: SessionId, signal?: AbortSignal): Promise<(SubagentDescendantListEntry & { readonly path: string })[]> { - this.assertOpen() - const entries = await this.ctx.subagents.listDescendants(rootId, signal) - const paths = new Map([[rootId, '']]) - const parents = new Map() - const result: (SubagentDescendantListEntry & { readonly path: string })[] = [] - for (const entry of entries) { - let parent = parents.get(entry.parentId) - if (parent === undefined) { - parent = await this.readParentNames(entry.parentId, signal) - parents.set(entry.parentId, parent) - } - // The subagent traversal emits parents before their descendants. - const prefix = paths.get(entry.parentId) as string - const segment = this.segment(parent, entry.id) - const path = prefix.length === 0 ? segment : `${prefix}/${segment}` - paths.set(entry.id, path) - result.push({ ...entry, path }) - } - return result - } - - private liveParentNames(session: Session): ParentNames { - // The required subagents service registers this projection before becoming available. - const children = this.ctx.sessionProjections.snapshot(session, ['subagentCatalog']).values.subagentCatalog as SubagentCatalogEntry[] - return { children, bindings: this.projection.stateOf(session).bindings } - } - - private async readParentNames(id: SessionId, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - using parent = await this.query().observeSession(id, signal === undefined ? {} : { signal }) - signal?.throwIfAborted() - return this.observedNames(parent.projections?.values) - } - - private observedNames(values: ProjectionSnapshot['values'] | undefined): ParentNames { - if (values?.subagentCatalog === undefined || values.subagentNames === undefined) { - throw new SubagentNameError('subagent catalog or name projection is unavailable', 'AGENT_NAMES_UNAVAILABLE') - } - return { children: values.subagentCatalog, bindings: values.subagentNames } - } - - private segment(parent: ParentNames, childId: SessionId): string { - if (!parent.children.some(child => child.id === childId)) { - throw new SubagentNameError('agent is absent from its parent catalog', 'AGENT_NAMES_UNAVAILABLE') - } - const name = parent.bindings.find(binding => binding.childId === childId)?.name - if (name !== undefined && parent.bindings.some(binding => binding.name === name && binding.childId !== childId - && parent.children.some(child => child.id === binding.childId))) { - throw new SubagentNameError(`agent name "${name}" refers to multiple catalog children`, 'CORRUPT_AGENT_NAMES') - } - return name ?? `legacy:${childId}` - } - - private assertOpen(): void { - if (this.closing) throw new SubagentNameError('subagent naming is unavailable while its plugin unloads', 'AGENT_NAMES_UNAVAILABLE') - } - - private query(): Context['sessionQuery'] { - const query = this.ctx.get('sessionQuery') - if (query === undefined) throw new SubagentNameError('agent naming requires the sessionQuery service for parent reads', 'AGENT_NAMES_UNAVAILABLE') - return query + resolveName(caller: Agent, name: string): SessionId { + const root = this.ctx.subagents.rootOf(caller) + if (name === 'lead') return root.id + const binding = this.projection.stateOf(root.session).bindings.find(binding => binding.name === name) + if (binding !== undefined) return binding.childId + if (name.startsWith('lead/')) throw new SubagentNameError(`agent "${name}" was not found; use list_agents to discover names or ids`, 'AGENT_NAME_NOT_FOUND') + return SessionId(name) } } diff --git a/packages/subagent/subagent-names/src/projection.ts b/packages/subagent/subagent-names/src/projection.ts index cfceed65a2..e082aecd57 100644 --- a/packages/subagent/subagent-names/src/projection.ts +++ b/packages/subagent/subagent-names/src/projection.ts @@ -1,11 +1,11 @@ -/** Parent-owned alias candidates; the subagent catalog establishes usable membership. @module */ +/** Root-Session aliases restored independently of subagent execution. @module */ import { z } from 'zod' import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -/** One immutable sibling name requested for a child identity. */ +/** One immutable full name bound to an accepted child identity. */ export interface SubagentNameBinding { readonly childId: SessionId readonly name: string @@ -14,8 +14,8 @@ export interface SubagentNameBinding { declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** - * A name candidate, usable only while the parent catalog contains its child. - * @param data - versioned child identity and sibling name. + * A root-owned alias for an accepted subagent identity. + * @param data - versioned child identity and complete name. */ 'subagent-name/bound': SubagentNameBinding & { readonly version: 1 } } @@ -27,24 +27,28 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -/** Names use one lower-case path segment; legacy ids have a separate prefix. */ +/** Requested sibling names use one lower-case path segment. */ export const nameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/) -const bindingSchema = z.object({ childId: z.string().transform(SessionId), name: nameSchema }).strict() +/** Bound paths include the root prefix and may include an unnamed parent's id. */ +export const fullNameSchema = z.string().startsWith('lead/').min(6) + +const bindingSchema = z.object({ childId: z.string().transform(SessionId), name: fullNameSchema }).strict() const boundSchema = bindingSchema.extend({ version: z.literal(1) }) const bindingsSchema = z.array(bindingSchema).refine( - bindings => new Set(bindings.map(binding => binding.childId)).size === bindings.length, - 'each child identity must have one name binding', + bindings => new Set(bindings.map(binding => binding.childId)).size === bindings.length + && new Set(bindings.map(binding => binding.name)).size === bindings.length, + 'child identities and names must each be unique', ) const stateSchema = z.object({ inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset), bindings: bindingsSchema, }).strict() -/** Recoverable direct-parent alias candidates, excluding inherited fork records. */ +/** Recoverable root aliases, excluding inherited fork records. */ export type SubagentNamesState = z.infer -/** Alias recovery is pure; catalog membership is checked when a name is read. */ +/** Alias recovery does not start agents or read other Sessions. */ export const subagentNamesProjection = { key: 'subagentNames', stateVersion: 1, @@ -56,9 +60,9 @@ export const subagentNamesProjection = { if (event.seq < state.inheritedEventCount) return state if (event.type === 'subagent-name/bound') { const { version: _version, ...binding } = boundSchema.parse(event.data) - const previous = state.bindings.find(entry => entry.childId === binding.childId) + const previous = state.bindings.find(entry => entry.childId === binding.childId || entry.name === binding.name) if (previous !== undefined) { - if (previous.name !== binding.name) throw new Error(`subagent name for ${binding.childId} is immutable`) + if (previous.name !== binding.name || previous.childId !== binding.childId) throw new Error('subagent names and identities must have one immutable binding') return state } return { ...state, bindings: [...state.bindings, binding] } diff --git a/packages/subagent/subagent-names/tests/names.spec.ts b/packages/subagent/subagent-names/tests/names.spec.ts index 9a6fd694fb..f2d98d99d0 100644 --- a/packages/subagent/subagent-names/tests/names.spec.ts +++ b/packages/subagent/subagent-names/tests/names.spec.ts @@ -2,18 +2,15 @@ import { randomUUID } from 'node:crypto' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { mountLocalActivations } from '../../subagent/tests/local-activation.ts' -import { bindSubagentActivation } from '../../subagent/tests/manager-internals.ts' import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts' -import { TestSessionQuery } from '../../subagent/tests/test-session-query.ts' import SubagentNames from '../src/index.ts' -import type { NamedSubagentActivationSpec } from '../src/index.ts' async function setup(script: ConstructorParameters[0] = []) { const ctx = new Context() @@ -21,382 +18,155 @@ async function setup(script: ConstructorParameters[0] = []) await mountAgentLoopTestDependencies(ctx) await mountLocalActivations(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - const queryFiber = await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) const namesFiber = await ctx.plugin(SubagentNames) - const adapter = new MockAdapter(script) - ctx.llm.registerAdapter(['mock'], adapter) - const parent = await ctx.agentLoop.create(SessionId(randomUUID()), { provider: 'mock', model: 'mock' }) - ctx.on('agent/pre-step', async ({ agent }, next) => agent === parent ? { kind: 'reject' } : next()) - const start = (overrides: Partial = {}) => ctx.subagentNames.startActivation({ - provider: 'spawn', label: 'Review', parent, delivery: 'parent', + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId(randomUUID()), agentOptions: { provider: 'mock', model: 'mock' }, + }) + const parent = parentHandle.agent + ctx.on('agent/pre-step', async ({ agent }, next) => agent.id === parent.id ? { kind: 'reject' } : next()) + const start = (creator: Agent = parent) => ctx.subagents.startActivation({ + provider: 'spawn', label: 'Review', parent: creator, delivery: 'parent', prompt: [{ type: 'text', text: 'Review the change' }], signal: new AbortController().signal, - ...overrides, }) - const catalog = () => ctx.sessionProjections.snapshot(parent.session, ['subagentCatalog']).values.subagentCatalog - return { ctx, parent, namesFiber, queryFiber, adapter, start, catalog } + return { ctx, parent, parentHandle, namesFiber, start } } -function localProvider(ctx: Context) { - const provider = ctx.subagents.getProvider('spawn') - if (provider?.prepareContinuable === undefined) throw new Error('spawn preparation is unavailable') - return provider -} +describe('root Session name mappings', () => { + it('binds an accepted child without changing its id, prompt, or execution', async () => { + const { ctx, parent, start } = await setup([textResponse('done')]) + const reservation = ctx.subagentNames.reserve(parent, 'reviewer') + const activation = await start() + expect(ctx.subagentNames.nameOf(parent, activation.childId)).toBe(activation.childId) + ctx.subagentNames.bind(parent, activation.childId, reservation.name) + reservation.dispose() + await activation.result -function externalRun() { - const result = Promise.withResolvers() - const dispose = vi.fn(async () => { result.resolve({ output: [], stopReason: 'aborted' }) }) - const run: SubagentRun = { id: SessionId(randomUUID()), result: result.promise, dispose } - return { result, dispose, run } -} - -function registerExternal(ctx: Context, run: SubagentRun): void { - ctx.subagents.registerProvider({ - name: 'external', inheritsParentContext: false, - capabilities: { agentOptions: false, agentPreset: false, outputSchema: false, depthLimit: false }, - start: async () => run, - }) -} - -describe('catalog-backed subagent names', () => { - it('allocates names, preserves explicit names, and resolves the same identities by name or id', async () => { - const { ctx, parent, start } = await setup([textResponse('first'), textResponse('second'), textResponse('third')]) - expect(await ctx.subagentNames.nameOf(parent)).toBe('lead') - const first = await start() - await first.result - const second = await start({ name: 'reviewer' }) - await second.result - const third = await start() - await third.result - - expect([first.name, second.name, third.name]).toEqual(['lead/agent-1', 'lead/reviewer', 'lead/agent-2']) - for (const activation of [first, second, third]) { - expect(await ctx.subagentNames.resolveName(parent, activation.name)).toBe(activation.childId) - expect(await ctx.subagentNames.resolveName(parent, activation.childId)).toBe(activation.childId) - } - expect(await ctx.subagentNames.resolveName(parent, 'lead')).toBe(parent.id) - expect(await ctx.subagentNames.resolveName(parent, parent.id)).toBe(parent.id) - expect(await ctx.subagentNames.listDescendants(parent.id)).toMatchObject([ - { id: first.childId, parentId: parent.id, path: 'agent-1' }, - { id: second.childId, parentId: parent.id, path: 'reviewer' }, - { id: third.childId, parentId: parent.id, path: 'agent-2' }, - ]) - const stored = await loadStoredSession(ctx.sessionPersistence, first.childId) - const initialMessage = stored.events.find(event => event.type === 'user/message') - expect(initialMessage?.data.content.slice(0, 2)).toEqual([ - { type: 'text', text: 'Review the change' }, - { type: 'text', text: 'Your name is "lead/agent-1". Your parent agent name is "lead".' }, - ]) + expect(ctx.subagentNames.nameOf(parent)).toBe('lead') + expect(ctx.subagentNames.nameOf(parent, activation.childId)).toBe('lead/reviewer') + expect(ctx.subagentNames.resolveName(parent, 'lead')).toBe(parent.id) + expect(ctx.subagentNames.resolveName(parent, 'lead/reviewer')).toBe(activation.childId) + expect(ctx.subagentNames.resolveName(parent, activation.childId)).toBe(activation.childId) + expect(ctx.subagentNames.resolveName(parent, 'unmapped-id')).toBe('unmapped-id') + expect(() => ctx.subagentNames.resolveName(parent, 'lead/misspelled')).toThrow('was not found') + const stored = await loadStoredSession(ctx.sessionPersistence, activation.childId) + const message = stored.events.find(event => event.type === 'user/message') + expect(message?.data.content[0]).toEqual({ type: 'text', text: 'Review the change' }) + expect(JSON.stringify(message)).not.toContain('Your name is') + expect(stored.events.some(event => event.type === 'subagent-name/bound')).toBe(false) }) - it('keeps unnamed SDK children discoverable by their legacy path and exact id', async () => { - const { ctx, parent } = await setup([textResponse('legacy')]) - const legacy = await ctx.subagents.startActivation({ - provider: 'spawn', label: 'SDK child', parent, delivery: 'caller', - prompt: [{ type: 'text', text: 'SDK work' }], signal: new AbortController().signal, - }) - await legacy.result - expect(await ctx.subagentNames.resolveName(parent, `lead/legacy:${legacy.childId}`)).toBe(legacy.childId) - expect(await ctx.subagentNames.resolveName(parent, legacy.childId)).toBe(legacy.childId) - expect(await ctx.subagentNames.listDescendants(parent.id)).toMatchObject([{ id: legacy.childId, path: `legacy:${legacy.childId}` }]) - }) - - it('keeps root naming and creation available without query while failing parent reads explicitly', async () => { - const { ctx, parent, queryFiber, start } = await setup([textResponse('child')]) - await queryFiber.dispose() - expect(await ctx.subagentNames.nameOf(parent)).toBe('lead') - const child = await start({ name: 'reviewer' }) - await child.result - await expect(ctx.subagentNames.resolveName(parent, child.name)).rejects.toMatchObject({ code: 'AGENT_NAMES_UNAVAILABLE' }) - }) - - it('rejects observations that do not supply the naming projections', async () => { - const { ctx, parent, start } = await setup([textResponse('child')]) - const child = await start({ name: 'reviewer' }) - await child.result - const observe = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery) - vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation((id, options) => - observe(id, { ...options, projectionMode: 'none' }), - ) - await expect(ctx.subagentNames.resolveName(parent, child.name)).rejects.toMatchObject({ code: 'AGENT_NAMES_UNAVAILABLE' }) - }) - - it.each(['cyclic', 'missing-ancestry', 'uncatalogued'] as const)( - 'rejects %s creation ancestry instead of inventing a name', async (kind) => { - const { ctx, parent } = await setup() - const id = SessionId(randomUUID()) - const child = await ctx.agents.create({ - sessionId: id, - meta: { origin: 'subagent', ...kind === 'missing-ancestry' ? {} : { parentSession: kind === 'cyclic' ? id : parent.id } }, - }) - bindSubagentActivation(ctx, child.agent, parent) - await expect(ctx.subagentNames.nameOf(child.agent)).rejects.toMatchObject({ - code: kind === 'cyclic' ? 'CORRUPT_AGENT_NAMES' : 'AGENT_NAMES_UNAVAILABLE', - }) - }, - ) - - it('rejects two cataloged children that claim the same sibling name', async () => { - const { ctx, parent, start } = await setup() - for (let index = 0; index < 2; index++) { - const childId = SessionId(randomUUID()) - parent.session.append('subagent-name/bound', { version: 1, childId, name: 'reviewer' }) - parent.session.append('subagent/catalog', { version: 2, childId, childCreatedAt: 1, mode: 'external' }) - } - await expect(ctx.subagentNames.resolveName(parent, 'lead/reviewer')).rejects.toMatchObject({ code: 'CORRUPT_AGENT_NAMES' }) - await expect(start()).rejects.toMatchObject({ code: 'CORRUPT_AGENT_NAMES' }) + it('reserves distinct defaults, rejects overlap, and reuses released unbound names', async () => { + const { ctx, parent } = await setup() + const first = ctx.subagentNames.reserve(parent) + const second = ctx.subagentNames.reserve(parent) + expect([first.name, second.name]).toEqual(['lead/agent-1', 'lead/agent-2']) + expect(() => ctx.subagentNames.reserve(parent, 'agent-1')).toThrow('already in use') + first.dispose() + first.dispose() + const retry = ctx.subagentNames.reserve(parent) + expect(retry.name).toBe('lead/agent-1') + first.dispose() + expect(() => ctx.subagentNames.reserve(parent, 'agent-1')).toThrow('already in use') + ctx.subagentNames.bind(parent, SessionId('accepted-child'), retry.name) + retry.dispose() + expect(() => ctx.subagentNames.reserve(parent, 'agent-1')).toThrow('already in use') + second.dispose() + const available = ctx.subagentNames.reserve(parent) + expect(available.name).toBe('lead/agent-2') + available.dispose() }) it.each(['', 'Reviewer', 'lead/reviewer', 'two words', '1reviewer', 'legacy:child'])( - 'rejects invalid sibling name %j before preparing a child', async (name) => { - const { ctx, start, catalog } = await setup() - const prepare = vi.spyOn(localProvider(ctx), 'prepareContinuable') - await expect(start({ name })).rejects.toMatchObject({ code: 'INVALID_AGENT_NAME' }) - expect(prepare).not.toHaveBeenCalled() - expect(catalog()).toEqual([]) + 'rejects invalid requested segment %j before reserving it', async (name) => { + const { ctx, parent } = await setup() + expect(() => ctx.subagentNames.reserve(parent, name)).toThrow('agent name must') + expect(ctx.sessionProjections.snapshot(parent.session).values.subagentNames).toEqual([]) }, ) - it('holds the sibling reservation throughout overlapping provider preparation', async () => { - const { ctx, start, catalog } = await setup([textResponse('first'), textResponse('second')]) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() - const prepare = vi.spyOn(localProvider(ctx), 'prepareContinuable').mockImplementationOnce(async () => { - entered.resolve(undefined) - await release.promise - return {} - }) - const first = start({ name: 'reviewer' }) - try { - await entered.promise - await expect(start({ name: 'reviewer' })).rejects.toMatchObject({ code: 'DUPLICATE_AGENT_NAME' }) - expect(catalog()).toEqual([]) - expect(prepare).toHaveBeenCalledTimes(1) - const independent = await start({ name: 'writer' }) - await independent.result - } finally { - release.resolve(undefined) - } - const accepted = await first - await accepted.result - expect(accepted.name).toBe('lead/reviewer') - await expect(start({ name: 'reviewer' })).rejects.toMatchObject({ code: 'DUPLICATE_AGENT_NAME' }) + it('keeps exact bindings idempotent while rejecting name and identity conflicts', async () => { + const { ctx, parent } = await setup() + const id = SessionId('accepted-child') + expect(() => { ctx.subagentNames.bind(parent, id, 'reviewer') }).toThrow('complete lead/... path') + ctx.subagentNames.bind(parent, id, 'lead/reviewer') + const seq = parent.session.seq + ctx.subagentNames.bind(parent, id, 'lead/reviewer') + expect(parent.session.seq).toBe(seq) + expect(() => { ctx.subagentNames.bind(parent, id, 'lead/renamed') }).toThrow('already bound') + expect(() => { ctx.subagentNames.bind(parent, SessionId('another-child'), 'lead/reviewer') }).toThrow('already bound') }) - it('does not occupy a name when its candidate append fails', async () => { - const { ctx, parent, start, catalog } = await setup([textResponse('retry')]) - const failure = new Error('name append failed') - const append = vi.spyOn(parent.session, 'append').mockImplementationOnce(() => { throw failure }) - await expect(start({ name: 'reviewer' })).rejects.toBe(failure) - append.mockRestore() - expect(catalog()).toEqual([]) - const retry = await start({ name: 'reviewer' }) - await retry.result - expect(await ctx.subagentNames.resolveName(parent, retry.name)).toBe(retry.childId) - }) - - it('ignores a candidate without a catalog child and reuses its name after failed preparation', async () => { - const { ctx, parent, start, catalog } = await setup([textResponse('retry')]) - const failure = new Error('provider preparation failed') - vi.spyOn(localProvider(ctx), 'prepareContinuable').mockRejectedValueOnce(failure) - await expect(start({ name: 'reviewer' })).rejects.toBe(failure) - expect(catalog()).toEqual([]) - expect(ctx.sessionProjections.snapshot(parent.session).values.subagentNames).toHaveLength(1) - await expect(ctx.subagentNames.resolveName(parent, 'lead/reviewer')).rejects.toMatchObject({ code: 'AGENT_NAME_NOT_FOUND' }) - const retry = await start({ name: 'reviewer' }) - await retry.result - expect(await ctx.subagentNames.resolveName(parent, retry.name)).toBe(retry.childId) - expect(ctx.sessionProjections.snapshot(parent.session).values.subagentNames).toHaveLength(2) - }) - - it('retains the name when catalog publication commits before startup reports failure', async () => { - const { ctx, parent, start, catalog } = await setup(['hang']) - const failure = new Error('catalog acknowledgement failed') - const append = parent.session.append.bind(parent.session) - const spy = vi.spyOn(parent.session, 'append').mockImplementation((...args) => { - const event = Reflect.apply(append, parent.session, args) as ReturnType - if (args[0] === 'subagent/catalog') throw failure - return event - }) - await expect(start({ name: 'reviewer' })).rejects.toBe(failure) - spy.mockRestore() - const childId = catalog()?.[0]?.id - expect(childId).toBeDefined() - expect(await ctx.subagentNames.resolveName(parent, 'lead/reviewer')).toBe(childId) - await expect(start({ name: 'reviewer' })).rejects.toMatchObject({ code: 'DUPLICATE_AGENT_NAME' }) - }) - - it('reads a resumed descendant name through its cold parent without restoring the parent', async () => { - const { ctx, parent, adapter, start } = await setup(['hang', 'hang', 'hang']) - const creator = await start({ name: 'reviewer' }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - const creatorAgent = ctx.agents.get(creator.childId) - expect(creatorAgent).toBeDefined() - const leaf = await start({ parent: creatorAgent!, name: 'facts' }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) - const leafAgent = ctx.agents.get(leaf.childId) - expect(await ctx.subagentNames.nameOf(leafAgent!)).toBe('lead/reviewer/facts') - await leaf.dispose() + it('records nested aliases only on the root and restores them without ancestor reads', async () => { + const { ctx, parent, parentHandle, start } = await setup(['hang', 'hang']) + const creator = await start() + ctx.subagentNames.bind(parent, creator.childId, 'lead/reviewer') + const creatorAgent = ctx.agents.get(creator.childId)! + const reservation = ctx.subagentNames.reserve(creatorAgent, 'facts') + const leaf = await start(creatorAgent) + ctx.subagentNames.bind(creatorAgent, leaf.childId, reservation.name) + reservation.dispose() + expect(ctx.subagentNames.nameOf(creatorAgent)).toBe('lead/reviewer') + expect(ctx.sessionProjections.snapshot(creatorAgent.session).values.subagentNames).toEqual([]) + expect(ctx.sessionProjections.snapshot(parent.session).values.subagentNames).toEqual([ + { childId: creator.childId, name: 'lead/reviewer' }, + { childId: leaf.childId, name: 'lead/reviewer/facts' }, + ]) await creator.dispose() expect(ctx.agents.get(creator.childId)).toBeUndefined() - expect(ctx.agents.get(leaf.childId)).toBeUndefined() - - await ctx.subagents.sendMessage(parent, leaf.childId, [{ type: 'text', text: 'Check again' }], { signal: new AbortController().signal }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) - const restored = ctx.agents.get(leaf.childId) - expect(restored).toBeDefined() - expect(await ctx.subagentNames.nameOf(restored!)).toBe('lead/reviewer/facts') - expect(await ctx.subagentNames.resolveName(parent, 'lead/reviewer/facts')).toBe(leaf.childId) - expect(await ctx.subagentNames.listDescendants(parent.id)).toMatchObject([ - { id: creator.childId, path: 'reviewer' }, { id: leaf.childId, path: 'reviewer/facts' }, - ]) - expect(ctx.agents.get(creator.childId)).toBeUndefined() + expect(ctx.subagentNames.nameOf(parent, leaf.childId)).toBe('lead/reviewer/facts') + await parentHandle.dispose() + const restored = await ctx.agents.resume({ resumeSessionId: parent.id }) + expect(ctx.subagentNames.resolveName(restored.agent, 'lead/reviewer/facts')).toBe(leaf.childId) + expect(ctx.get('sessionQuery')).toBeUndefined() }) - it('binds an external child only after the accepted provider identity is known', async () => { - const { ctx, parent, start } = await setup() - const backend = externalRun() - registerExternal(ctx, backend.run) - const activation = await start({ provider: 'external', name: 'reviewer', delivery: 'parent' }) - expect(activation.childId).toBe(backend.run.id) - expect(await ctx.subagentNames.resolveName(parent, activation.name)).toBe(backend.run.id) - await expect(ctx.subagentNames.resolveName(parent, `${activation.name}/child`)).rejects.toMatchObject({ code: 'AGENT_NAME_NOT_FOUND' }) - await activation.dispose() - expect(backend.dispose).toHaveBeenCalledTimes(1) + it('uses a stable id prefix for children whose parent has no name', async () => { + const { ctx, parent, start } = await setup(['hang']) + const creator = await start() + const creatorAgent = ctx.agents.get(creator.childId)! + expect(ctx.subagentNames.nameOf(creatorAgent)).toBe(creator.childId) + const reservation = ctx.subagentNames.reserve(creatorAgent, 'facts') + expect(reservation.name).toBe(`lead/legacy:${creator.childId}/facts`) + const childId = SessionId('accepted-leaf') + ctx.subagentNames.bind(creatorAgent, childId, reservation.name) + reservation.dispose() + ctx.subagentNames.bind(parent, creator.childId, 'lead/reviewer') + expect(ctx.subagentNames.nameOf(parent, childId)).toBe(`lead/legacy:${creator.childId}/facts`) }) - it('reports both binding and disposal failures for an accepted external child', async () => { - const { ctx, parent, start } = await setup() - const backend = externalRun() - registerExternal(ctx, backend.run) - const bindingFailure = new Error('binding failed') - const cleanupFailure = new Error('cleanup failed') - backend.dispose.mockImplementationOnce(async () => { - backend.result.resolve({ output: [], stopReason: 'aborted' }) - throw cleanupFailure - }) - const append = parent.session.append.bind(parent.session) - vi.spyOn(parent.session, 'append').mockImplementation((...args) => { - if (args[0] === 'subagent-name/bound') throw bindingFailure - return Reflect.apply(append, parent.session, args) as ReturnType - }) - const error = await start({ provider: 'external', name: 'reviewer' }).catch((failure: unknown) => failure) - expect(error).toMatchObject({ code: 'SUBAGENT_NAME_BIND_FAILED', childId: backend.run.id }) - expect(error).toBeInstanceOf(Error) - if (!(error instanceof Error)) throw new Error('expected named-start failure') - expect(error.message).toContain('execution may still be active') - expect(error.cause).toBeInstanceOf(AggregateError) - if (!(error.cause instanceof AggregateError)) throw new Error('expected combined failure') - expect(error.cause.errors).toHaveLength(2) - expect(error.cause.errors[0]).toBe(bindingFailure) - expect(error.cause.errors[1]).toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED', cause: cleanupFailure }) + it('does not stop an accepted child when the alias append fails', async () => { + const { ctx, parent, start } = await setup(['hang']) + const reservation = ctx.subagentNames.reserve(parent, 'reviewer') + const child = await start() + const failure = new Error('binding append failed') + const append = vi.spyOn(parent.session, 'append').mockImplementationOnce(() => { throw failure }) + expect(() => { ctx.subagentNames.bind(parent, child.childId, reservation.name) }).toThrow(failure) + append.mockRestore() + reservation.dispose() + expect(ctx.agents.get(child.childId)).toBeDefined() + expect(ctx.subagentNames.nameOf(parent, child.childId)).toBe(child.childId) + const retry = ctx.subagentNames.reserve(parent, 'reviewer') + retry.dispose() + await child.dispose() }) - it('reports the started external child and awaits that receipt disposal when binding fails', async () => { - const { ctx, parent, start } = await setup() - const backend = externalRun() - const unrelated = externalRun() - registerExternal(ctx, backend.run) - ctx.subagents.registerProvider({ - name: 'unrelated', inheritsParentContext: false, - capabilities: { agentOptions: false, agentPreset: false, outputSchema: false, depthLimit: false }, - start: async () => unrelated.run, - }) - const existing = await start({ provider: 'unrelated', name: 'writer', delivery: 'parent' }) - const cleaning = Promise.withResolvers() - const cleaned = Promise.withResolvers() - backend.dispose.mockImplementation(async () => { - backend.result.resolve({ output: [], stopReason: 'aborted' }) - cleaning.resolve(undefined) - await cleaned.promise - }) - const failure = new Error('external binding append failed') - const append = parent.session.append.bind(parent.session) - const spy = vi.spyOn(parent.session, 'append').mockImplementation((...args) => { - if (args[0] === 'subagent-name/bound') throw failure - return Reflect.apply(append, parent.session, args) as ReturnType - }) - const starting = start({ provider: 'external', name: 'reviewer', delivery: 'parent' }) - const observed = vi.fn() - void starting.then(observed, observed) - const rejected = expect(starting).rejects.toMatchObject({ - code: 'SUBAGENT_NAME_BIND_FAILED', childId: backend.run.id, cause: failure, - }) - try { - await cleaning.promise - expect(backend.dispose).toHaveBeenCalledTimes(1) - expect(unrelated.dispose).not.toHaveBeenCalled() - expect(observed).not.toHaveBeenCalled() - } finally { - cleaned.resolve(undefined) - } - await rejected - spy.mockRestore() - await existing.dispose() - }) - - it('refuses to overwrite a historical name when an external provider reuses its child id', async () => { - const { ctx, parent, start } = await setup() - const first = externalRun() - const next = externalRun() - const reused = { ...next.run, id: first.run.id } - const providerStart = vi.fn().mockResolvedValueOnce(first.run).mockResolvedValueOnce(reused) - ctx.subagents.registerProvider({ - name: 'external', inheritsParentContext: false, - capabilities: { agentOptions: false, agentPreset: false, outputSchema: false, depthLimit: false }, - start: providerStart, - }) - const original = await start({ provider: 'external', name: 'reviewer', delivery: 'parent' }) - await original.dispose() - await expect(start({ provider: 'external', name: 'writer', delivery: 'parent' })).rejects.toMatchObject({ - code: 'SUBAGENT_NAME_BIND_FAILED', childId: first.run.id, - }) - expect(first.dispose).toHaveBeenCalledTimes(1) - expect(next.dispose).toHaveBeenCalledTimes(1) - expect(ctx.sessionProjections.snapshot(parent.session).values.subagentNames).toEqual([ - { childId: first.run.id, name: 'reviewer' }, - ]) - expect(await ctx.subagentNames.resolveName(parent, original.name)).toBe(first.run.id) - await expect(ctx.subagentNames.resolveName(parent, 'lead/writer')).rejects.toMatchObject({ code: 'AGENT_NAME_NOT_FOUND' }) - }) - - it('aborts pending preparation and drains it before plugin disposal completes', async () => { - const { ctx, parent, namesFiber, start, catalog } = await setup() + it('unregisters only its projection and leaves accepted execution with its owner', async () => { + const { ctx, parent, namesFiber, start } = await setup(['hang']) const names = ctx.subagentNames - const entered = Promise.withResolvers() - const aborted = Promise.withResolvers() - const cleanup = Promise.withResolvers() - vi.spyOn(localProvider(ctx), 'prepareContinuable').mockImplementationOnce(async ({ signal }) => { - entered.resolve(signal) - signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) - await aborted.promise - await cleanup.promise - signal.throwIfAborted() - return {} - }) - const starting = start({ name: 'reviewer' }) - const rejected = expect(starting).rejects.toThrow('unloading') - const signal = await entered.promise - const disposal = namesFiber.dispose() - const disposed = vi.fn() - void disposal.then(disposed, disposed) - try { - await aborted.promise - expect(signal.aborted).toBe(true) - expect(disposed).not.toHaveBeenCalled() - expect(catalog()).toEqual([]) - const bindings = ctx.sessionProjections.snapshot(parent.session).values.subagentNames - expect(bindings).toHaveLength(1) - expect(bindings?.[0]?.childId).toEqual(expect.any(String)) - expect(bindings?.[0]?.name).toBe('reviewer') - await expect(names.nameOf(parent)).rejects.toMatchObject({ code: 'AGENT_NAMES_UNAVAILABLE' }) - } finally { - cleanup.resolve(undefined) - } - await rejected - await disposal + const reservation = names.reserve(parent, 'reviewer') + const child = await start() + await namesFiber.dispose() expect(ctx.get('subagentNames')).toBeUndefined() expect(ctx.sessionProjections.snapshot(parent.session).values.subagentNames).toBeUndefined() + expect(ctx.agents.get(child.childId)).toBeDefined() + // Accepted callers retain their projection handle while completing a binding. + names.bind(parent, child.childId, reservation.name) + reservation.dispose() + await ctx.plugin(SubagentNames) + expect(ctx.subagentNames.nameOf(parent, child.childId)).toBe('lead/reviewer') + await child.dispose() }) }) diff --git a/packages/subagent/subagent-names/tests/projection.spec.ts b/packages/subagent/subagent-names/tests/projection.spec.ts index 1bc5bffdfc..d5095a30d7 100644 --- a/packages/subagent/subagent-names/tests/projection.spec.ts +++ b/packages/subagent/subagent-names/tests/projection.spec.ts @@ -12,7 +12,7 @@ const header: SessionHeader = { isSeeded: false, } -function bound(seq: number, childId = 'child', name = 'worker'): SessionEvent<'subagent-name/bound'> { +function bound(seq: number, childId = 'child', name = 'lead/worker'): SessionEvent<'subagent-name/bound'> { return { type: 'subagent-name/bound', seq: SessionSeq(seq), time: seq, data: { version: 1, childId: SessionId(childId), name }, @@ -32,19 +32,21 @@ describe('subagent names projection', () => { it('keeps each child binding immutable without mutating earlier states', () => { const initial = subagentNamesProjection.init(header, SessionLogOffset(0)) const first = subagentNamesProjection.apply(initial, bound(0)) - const second = subagentNamesProjection.apply(first, bound(1, 'other-child', 'reviewer')) + const second = subagentNamesProjection.apply(first, bound(1, 'other-child', 'lead/reviewer')) expect(initial.bindings).toEqual([]) - expect(first.bindings).toEqual([{ childId: 'child', name: 'worker' }]) + expect(first.bindings).toEqual([{ childId: 'child', name: 'lead/worker' }]) expect(second.bindings).toEqual([ - { childId: 'child', name: 'worker' }, - { childId: 'other-child', name: 'reviewer' }, + { childId: 'child', name: 'lead/worker' }, + { childId: 'other-child', name: 'lead/reviewer' }, ]) - expect(() => subagentNamesProjection.apply(second, bound(2, 'child', 'renamed'))) - .toThrow('subagent name for child is immutable') + expect(() => subagentNamesProjection.apply(second, bound(2, 'child', 'lead/renamed'))) + .toThrow('one immutable binding') + expect(() => subagentNamesProjection.apply(second, bound(2, 'different-child', 'lead/worker'))) + .toThrow('one immutable binding') expect(second.bindings).toEqual([ - { childId: 'child', name: 'worker' }, - { childId: 'other-child', name: 'reviewer' }, + { childId: 'child', name: 'lead/worker' }, + { childId: 'other-child', name: 'lead/reviewer' }, ]) }) @@ -65,22 +67,22 @@ describe('subagent names projection', () => { it('excludes inherited bindings and admits the event at the exact inherited cut', () => { const initial = subagentNamesProjection.init(header, SessionLogOffset(2)) - expect(subagentNamesProjection.apply(initial, bound(0, 'child', 'inherited'))).toBe(initial) - expect(subagentNamesProjection.apply(initial, bound(1, 'other-child', 'inherited-too'))).toBe(initial) + expect(subagentNamesProjection.apply(initial, bound(0, 'child', 'lead/inherited'))).toBe(initial) + expect(subagentNamesProjection.apply(initial, bound(1, 'other-child', 'lead/inherited-too'))).toBe(initial) const own = subagentNamesProjection.apply(initial, bound(2)) - expect(own.bindings).toEqual([{ childId: 'child', name: 'worker' }]) + expect(own.bindings).toEqual([{ childId: 'child', name: 'lead/worker' }]) expect(own.inheritedEventCount).toBe(2) }) it.each([ - { version: 2, childId: 'child', name: 'worker' }, - { childId: 'child', name: 'worker' }, - { version: 1, childId: 1, name: 'worker' }, - { version: 1, name: 'worker' }, + { version: 2, childId: 'child', name: 'lead/worker' }, + { childId: 'child', name: 'lead/worker' }, + { version: 1, childId: 1, name: 'lead/worker' }, + { version: 1, name: 'lead/worker' }, { version: 1, childId: 'child' }, { version: 1, childId: 'child', name: 1 }, - { version: 1, childId: 'child', name: 'worker', extra: true }, + { version: 1, childId: 'child', name: 'lead/worker', extra: true }, ...['', 'Worker', '1worker', 'parent/worker', 'legacy:child', 'worker name'].map(name => ({ version: 1, childId: 'child', name, })), @@ -99,10 +101,11 @@ describe('subagent names projection', () => { { inheritedEventCount: '0', bindings: [] }, { inheritedEventCount: 0, bindings: {} }, { inheritedEventCount: 0, bindings: [{ childId: 'child' }] }, - { inheritedEventCount: 0, bindings: [{ childId: 1, name: 'worker' }] }, + { inheritedEventCount: 0, bindings: [{ childId: 1, name: 'lead/worker' }] }, { inheritedEventCount: 0, bindings: [{ childId: 'child', name: 'parent/worker' }] }, - { inheritedEventCount: 0, bindings: [{ childId: 'child', name: 'worker', version: 1 }] }, - { inheritedEventCount: 0, bindings: [{ childId: 'child', name: 'worker' }, { childId: 'child', name: 'reviewer' }] }, + { inheritedEventCount: 0, bindings: [{ childId: 'child', name: 'lead/worker', version: 1 }] }, + { inheritedEventCount: 0, bindings: [{ childId: 'child', name: 'lead/worker' }, { childId: 'child', name: 'lead/reviewer' }] }, + { inheritedEventCount: 0, bindings: [{ childId: 'child', name: 'lead/worker' }, { childId: 'other', name: 'lead/worker' }] }, { inheritedEventCount: 0, bindings: [], extra: true }, ])('rejects malformed checkpoint state %j', (state) => { expect(subagentNamesProjection.stateSchema.safeParse(state).success).toBe(false) @@ -112,16 +115,16 @@ describe('subagent names projection', () => { const projections = await registry() const inheritedEventCount = SessionLogOffset(1) const events: SessionEvent[] = [ - bound(0, 'ancestor-child', 'inherited'), + bound(0, 'ancestor-child', 'lead/inherited'), bound(1), bound(2), - bound(3, 'other-child', 'reviewer'), + bound(3, 'other-child', 'lead/reviewer'), { type: 'turn/start', seq: SessionSeq(4), time: 4, data: { turn: 1 } }, ] const prefix = projections.restore({}, events.slice(0, 2), SessionLogOffset(0), header, inheritedEventCount) expect(prefix.checkpoint.subagentNames).toEqual({ ver: 1, seq: 1, - val: { inheritedEventCount: 1, bindings: [{ childId: 'child', name: 'worker' }] }, + val: { inheritedEventCount: 1, bindings: [{ childId: 'child', name: 'lead/worker' }] }, }) const resumed = projections.restore( prefix.checkpoint, events.slice(2), SessionLogOffset(2), header, inheritedEventCount, @@ -132,8 +135,8 @@ describe('subagent names projection', () => { expect(resumed.snapshot).toEqual({ asOfSeq: 4, values: { subagentNames: [ - { childId: 'child', name: 'worker' }, - { childId: 'other-child', name: 'reviewer' }, + { childId: 'child', name: 'lead/worker' }, + { childId: 'other-child', name: 'lead/reviewer' }, ] }, }) expect(projections.viewCheckpoint(resumed.checkpoint)).toEqual(resumed.snapshot.values) @@ -150,11 +153,11 @@ describe('subagent names projection', () => { expect(projections.viewCheckpoint(incompatible)).toEqual({}) expect(() => projections.restore( - incompatible, [bound(1, 'other-child', 'reviewer')], SessionLogOffset(1), header, SessionLogOffset(0), + incompatible, [bound(1, 'other-child', 'lead/reviewer')], SessionLogOffset(1), header, SessionLogOffset(0), )).toThrow(/re-read from seq 0/) expect(projections.restore( incompatible, [bound(0)], SessionLogOffset(0), header, SessionLogOffset(0), - ).snapshot.values.subagentNames).toEqual([{ childId: 'child', name: 'worker' }]) + ).snapshot.values.subagentNames).toEqual([{ childId: 'child', name: 'lead/worker' }]) }) it('rejects a version-matching checkpoint with invalid binding fields', async () => { diff --git a/packages/subagent/subagent-names/tsconfig.json b/packages/subagent/subagent-names/tsconfig.json index eed652e6e2..b36fe3f8ef 100644 --- a/packages/subagent/subagent-names/tsconfig.json +++ b/packages/subagent/subagent-names/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../../session/session-projection" }, - { - "path": "../../session-query/session-query" - }, { "path": "../subagent" } diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts index 97a209367a..2493d2b878 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts @@ -12,7 +12,6 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import SubagentNames from '@deepseek-ai/dsh-subagent-names' -import { TestSessionQuery } from '../../subagent/tests/test-session-query.ts' import * as Spawn from '../src/index.ts' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' @@ -42,7 +41,6 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(JsonlSessionPersistence, { root: join(workdir, '.sessions') }) await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) - await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentNames) await ctx.plugin(Spawn, { providerName: 'spawn' }) // The model-facing subagent tool, bound to the spawn backend. diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index 1f5d8deb35..01a32a4e62 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -24,20 +24,20 @@ en: a30b863b9a9bca72 zh: e8c5168f98eaf88a /deepseek-ai-dsh-tool-subagent-control/use-this-package/interrupt-agent: - en: b0eceb2ffde56e6c - zh: b47bc05cf400a429 + en: 4116c0be76097afd + zh: ffe3beac5025344a /deepseek-ai-dsh-tool-subagent-control/use-this-package/wait-agent: en: 41ed5ec6e82e0282 zh: 0545e0719d5565d8 /deepseek-ai-dsh-tool-subagent-control/use-this-package/list-agents: - en: 79d2508e6231c00b - zh: 3923348b97d77bd0 + en: bfc34a440678540a + zh: f83e5519fa2761cb /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation: en: c5cdf01a78c990f2 zh: 53c180d726ce4502 /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/design-concept: - en: ca2f40711df98a11 - zh: 9a1d1ef5d0fb7d5d + en: 27f73437315a97ca + zh: 47999ba2c9c8c394 /deepseek-ai-dsh-tool-subagent-control/understand-the-implementation/delivery-and-signal-ownership: en: 65137f5fe14ebb7f zh: ce00e4f32709246a @@ -63,26 +63,26 @@ en: 5eb1d46a94b7ec1b zh: 5670760ca695d4ad /deepseek-ai-dsh-tool-subagent-control/model-experience/tool-schema/kv-cache-effect: - en: 89619e31f3ea7585 - zh: 5681bc8096cd8e32 + en: c399622fde875897 + zh: 11e0b3bcd71d8d8e /deepseek-ai-dsh-tool-subagent-control/model-experience/communication-context: en: 95d76d1aba983086 zh: b645a3837ec17ef4 /deepseek-ai-dsh-tool-subagent-control/model-experience/communication-context/what-the-model-sees: - en: 9a8ddc3e8e466aeb - zh: 7df44948893bfde0 + en: adb5b38af8f1cfb1 + zh: 2a7f2779ed5e8a9f /deepseek-ai-dsh-tool-subagent-control/model-experience/communication-context/token-effect: en: 24d9c15986cb4d3d zh: 6fb66cf84f8bc812 /deepseek-ai-dsh-tool-subagent-control/model-experience/communication-context/kv-cache-effect: - en: de3af33c2e6949ef - zh: 0f57b7f40419fcad + en: be04fa8fa539cad0 + zh: 797fd37af7d32f90 /deepseek-ai-dsh-tool-subagent-control/model-experience/interrupt-result: en: d64e936e83e89a2b zh: b9905c33af6cca45 /deepseek-ai-dsh-tool-subagent-control/model-experience/interrupt-result/what-the-model-sees: - en: 0465c4407ae55795 - zh: 1a9e1623afb92a29 + en: 00d6ca03eab6e7fa + zh: 202e242f06ea8818 /deepseek-ai-dsh-tool-subagent-control/model-experience/interrupt-result/token-effect: en: 5d1bcef28b5f18da zh: 027d67d93d660e76 @@ -105,8 +105,8 @@ en: 094e55d258f2ec2e zh: f364bc9046530bff /deepseek-ai-dsh-tool-subagent-control/model-experience/listing-result/what-the-model-sees: - en: 031b07c564bf3e6e - zh: 80040e0c55d158a9 + en: 0e68be81042a70d0 + zh: 4ed29257cd12c5f4 /deepseek-ai-dsh-tool-subagent-control/model-experience/listing-result/token-effect: en: 027aaeb808238388 zh: 5b974abc429d9e23 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 7195afdd0a..ba5a766f4d 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -49,7 +49,7 @@ In named mode, `name` selects a full path or Session id of another agent in the ### interrupt_agent -In named mode, `name` identifies a descendant by full path or Session id. External executions stop permanently and cannot receive follow-ups. Local targets stop only their current turn: queued messages stay parked until a later `send_message`, descendants keep running, and the child stays available for follow-ups. Cancellation during name resolution leaves the target running. The call returns when the stop request is accepted, not when the target is quiet; interrupting an already-finished agent is an accepted no-op, and self, sibling, stale, and non-ancestor callers get errored results. +In named mode, `name` identifies a descendant by full path or Session id. External executions stop permanently and cannot receive follow-ups. Local targets stop only their current turn: queued messages stay parked until a later `send_message`, descendants keep running, and the child stays available for follow-ups. The call returns when the stop request is accepted, not when the target is quiet; interrupting an already-finished agent is an accepted no-op, and self, sibling, stale, and non-ancestor callers get errored results. ### wait_agent @@ -58,7 +58,7 @@ Waits for the exact caller’s next-step inbox. Pending input returns immediatel ### list_agents -Lists the root and all reachable continuable descendants, including the caller, in stable pre-order. Named rows carry full paths, with `lead` for the root; descendants carry their direct parent path and depth from the root. Unnamed children use `legacy:` path segments. Each child’s label comes from the `description` supplied at creation. Live status is `running` or `inactive`. External entries are omitted without reading child Sessions; readable one-shot children remain traversal nodes. Unknown modes and unreadable catalogs produce diagnostics. Ordinary Session forks lead separate subagent trees. +Lists the root and all reachable continuable descendants, including the caller, in stable pre-order. Rows retain Session ids, parent ids, and depth from the root. The optional naming plugin adds `name` and `parentName` when a mapping is available; the root is named `lead`. Unnamed children retain their ids. Each child’s label comes from the `description` supplied at creation. Live status is `running` or `inactive`. External entries are omitted without reading child Sessions; readable one-shot children remain traversal nodes. Unknown modes and unreadable catalogs produce diagnostics. Ordinary Session forks lead separate subagent trees. ----- @@ -72,7 +72,7 @@ This section explains what the tools delegate to the subagent service; the obser ### Design concept -The optional `ctx.subagentNames` resolves names; messaging, interruption, and catalog traversal delegate to `ctx.subagents`; residency, cold resume, and authorization belong to that service. The wait tool subscribes to the exact caller’s inbox and wait signals before starting the timer and checking pending next-step input, and removes its listeners and timer on every settlement. +The optional `ctx.subagentNames` resolves names synchronously; messaging, interruption, and catalog traversal delegate to `ctx.subagents`; residency, cold resume, and authorization belong to that service. The wait tool subscribes to the exact caller’s inbox and wait signals before starting the timer and checking pending next-step input, and removes its listeners and timer on every settlement. ### Delivery and signal ownership @@ -120,13 +120,13 @@ Fixed schema cost per request, independent of the number or descriptions of chil #### KV Cache effect -Prefix-stable; the schemas do not change at runtime, and a parent and its continuable children, including fork children, receive identical definitions. +The schema prefix stays stable while the naming service’s availability is unchanged. A parent and its continuable children, including fork children, inherit the same definitions. ### Communication context #### What the model sees -The logged `subagent:communication` runtime context tells delegated children their own and direct creator’s JSON-encoded names (or the parent id without naming) and how to send findings that change the parent’s next action. Sending a message does not end the turn. Root Agents and ordinary Session forks receive no communication context; delegated children receive it only when the standard messaging definition is admitted in the current scope and step. Automatic result delivery remains owned by the subagent service. +The logged `subagent:communication` runtime context tells delegated children their direct creator’s JSON-encoded Session id and how to send findings that change the parent’s next action. Named controls also accept this id. Sending a message does not end the turn. Root Agents and ordinary Session forks receive no communication context; delegated children receive it only when the standard messaging definition is admitted in the current scope and step. Automatic result delivery remains owned by the subagent service. #### Token effect @@ -134,13 +134,13 @@ One short context entry per delegated child, independent of tree size. #### KV Cache effect -Communication guidance is retained as runtime context after inherited history, leaving the shared system prompt and tool definitions unchanged between parent and child. Cold resume derives the parent address again from durable records. If the runtime root is unavailable, guidance retains the parent id; corrupt naming records reject assembly. +Communication guidance is retained as runtime context after inherited history, leaving the shared system prompt and tool definitions unchanged between parent and child. The parent id comes from the child Session header; prompt assembly does not wait for a name binding or read the name map. ### Interrupt result #### What the model sees -`interrupt requested for agent
` on acceptance. An unauthorized caller — self, sibling, stale, or non-ancestor — is an errored result naming the rejection; a known settled target still renders the acceptance line. Named mode rejects unknown addresses; id mode retains an accepted no-op for absent targets. +`interrupt requested for agent
` on acceptance. An unauthorized caller — self, sibling, stale, or non-ancestor — is an errored result naming the rejection; a known settled target still renders the acceptance line. Unknown name paths are rejected; an absent raw Session id retains an accepted no-op. #### Token effect @@ -168,7 +168,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -The first row is `
[] — root agent`. Each continuable descendant follows as `
[] parent=
depth= —