From b76fe343fe15275f967988786f106d10e9b86a68 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 10 Sep 2026 12:42:44 +0800 Subject: [PATCH] fix(agent-team): keep member identity in durable reminders --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 2 +- .../feature/2026-08-05-agent-teams.zh.md | 2 +- .../headless/tests/fixtures/team-llm.mjs | 8 +- .../headless/tests/headless.expected.e2e.ts | 17 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/system-prompt.i18n.yaml | 4 +- docs/subsystems/system-prompt.md | 3 - docs/subsystems/system-prompt.zh.md | 3 - packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 4 +- packages/core/system-prompt/README.zh.md | 4 +- packages/core/system-prompt/src/index.ts | 11 -- .../core/system-prompt/tests/scoped.spec.ts | 27 --- .../system-prompt/tests/system-prompt.spec.ts | 15 -- .../tool-agent-team/README.i18n.yaml | 4 +- .../experimental/tool-agent-team/README.md | 6 +- .../experimental/tool-agent-team/README.zh.md | 6 +- .../experimental/tool-agent-team/package.json | 6 +- .../experimental/tool-agent-team/src/index.ts | 44 ++++- .../tool-agent-team/tests/tool-team.spec.ts | 162 +++++++++++++----- .../tool-agent-team/tsconfig.json | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 3 +- pnpm-lock.yaml | 3 + 29 files changed, 208 insertions(+), 155 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 4a97725abc..2a08b14b09 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 359fc913f94f0fb8eab81238e718a840fdc2324a -2026-08-05-agent-teams.zh.md: 8126d135ad7b7867c1eeea1f0ccff70ca47a0311 +2026-08-05-agent-teams.md: b9753afc6ed88f89ea6aef231b87cbfc12ab7748 +2026-08-05-agent-teams.zh.md: 6b8441b329f8108efc297105f6206e58ebc82333 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 359fc913f9..b9753afc6e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -22,7 +22,7 @@ The Lead must wait for required work before its final answer. Process teardown r ## Team identity -Team identity is a required runtime-context contribution wrapped in ``. The shared system policy and tool schemas stay uniform across members, while the role, name, and Team id are logged after inherited history. Team id distinguishes an ordinary fork whose role and name remain lead. The existing snapshot projection owns deduplication, cold recovery, and reinjection after compaction; suppression rejects assembly. Putting identity at the end of the system prompt still changes the prefix before history, and adding it only to the first task omits recovery and compaction handling. Existing system-embedded identities may require a one-time prompt reconciliation when loaded; retained event generations are unchanged. +The tool plugin appends a durable user-role `` before a teammate's first task, stating only `You are teammate "".`. Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller's Team and accept member names, so the model needs no Team id. Ordinary Leads receive no identity message. An ordinary fork with an inherited teammate reminder receives `You are the Team Lead.`; the inherited log identifies this case even when a summary shadows the reminder. The pre-step listener reuses the latest retained reminder and restores it after compaction removes it, including after cold recovery. It preserves rejected or emptied steps and does not depend on optional runtime-context assembly. Putting identity in the system prompt changes the prefix before inherited history; trusting a one-time reminder alone leaves its survival to summarization. Existing system-embedded identities may require a one-time prompt reconciliation; retained event generations are unchanged. ## Provisioning and recovery diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 8126d135ad..6b8441b329 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -22,7 +22,7 @@ Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍 ## Team identity -Team 身份是包在 `` 中的必需运行时上下文贡献。共享 system 策略和工具 schema 在成员间保持一致,role、name 和 Team id 则记录在继承历史之后。Team id 区分 role 和 name 仍为 lead 的普通 fork。现有快照投影负责去重、冷恢复和压缩后的补发;禁用上下文会使组装失败。把身份移到 system prompt 末尾仍会在历史之前改变前缀,只把身份加入首次任务则遗漏恢复与压缩处理。加载已有的 system 内嵌身份时可能需要一次提示词协调;保留的事件格式代际保持不变。 +工具插件在 teammate 的首次任务前追加持久化 user-role ``,只声明 `You are teammate "".`。共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。普通 Lead 不接收身份消息。继承 teammate 提醒的普通 fork 会收到 `You are the Team Lead.`;即使摘要遮蔽了提醒,继承日志仍可识别这种情况。pre-step 监听器复用最新保留的提醒,并在压缩移除提醒后恢复它,冷恢复后也同样处理。它保留被拒绝或清空的步骤,不依赖可选的运行时上下文组装。把身份放进 system prompt 会在继承历史之前改变前缀;只信赖一次性提醒则让摘要决定身份是否保留。已有的 system 内嵌身份可能需要一次提示词协调;保留的事件格式代际保持不变。 ## Provisioning and recovery 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 9a67968f66..cfee14b94b 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -178,13 +178,9 @@ class TeamFixtureAdapter extends LlmAdapter { && message.source.kind === 'plugin' && message.source.form === 'snapshot' && message.source.sections.some(section => section.name === 'team:identity')) const identity = snapshot?.source.sections.find(section => section.name === 'team:identity')?.text - if (!identity?.startsWith('\nYour Team role is ') - || !identity.endsWith('\n')) { - throw new Error('Team request is missing its durable identity reminder') - } - const chunks = identity.includes('your Team name is researcher;') + const chunks = identity === '\nYou are teammate "researcher".\n' ? researcher(options.messages) - : identity.includes('your Team name is implementer;') + : identity === '\nYou are teammate "implementer".\n' ? implementer(options.messages) : lead(options.messages) 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 ad7e86d0f0..db06b256e1 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -660,7 +660,7 @@ describe('headless stream-json snapshots', () => { if (data.name !== 'team_task_update' || typeof data.arguments !== 'string') return false return (JSON.parse(data.arguments) as JsonObject).action === 'complete' }) - const identityReminders = logs.map((log) => { + const identityReminders = logs.flatMap((log) => { const identities = parseJsonl(log.content).flatMap((row) => { if (row.type !== 'user/message') return [] const source = (row.data as JsonObject).source as JsonObject @@ -669,8 +669,12 @@ describe('headless stream-json snapshots', () => { return sections.filter(section => (section as JsonObject).name === 'team:identity') }) const reminder = (identities.at(-1) as JsonObject | undefined)?.text - if (typeof reminder !== 'string') throw new Error('Team Session has no identity reminder') - return reminder.replace(String(parent.header.id), '') + if (log === parent) { + expect(identities).toEqual([]) + return [] + } + if (typeof reminder !== 'string') throw new Error('Teammate Session has no identity reminder') + return [reminder] }).sort() projection = { sessions: logs.length, @@ -712,13 +716,10 @@ describe('headless stream-json snapshots', () => { "deliveredMessages": 2, "identityReminders": [ " - Your Team role is lead; your Team name is lead; Team id is . + You are teammate "implementer". ", " - Your Team role is teammate; your Team name is implementer; Team id is . - ", - " - Your Team role is teammate; your Team name is researcher; Team id is . + You are teammate "researcher". ", ], "memberEdges": 4, diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2b29524410..8f9b591c55 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: e07d5aca60afd32c2f4d8084d14c01cdc1ea3b06 -config-catalog.zh.md: aade7e70610a32db2ecc5a418535130f2cff3a69 +config-catalog.md: 38e2b4a7ba28c7823716443b5f4605dd4f42ddab +config-catalog.zh.md: 83a803f735f14b7853c908dc0f2b1994ca1cb245 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e07d5aca60..38e2b4a7ba 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -733,7 +733,7 @@ export interface Config { } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/experimental/tool-agent-team/src/index.ts:19`](../packages/experimental/tool-agent-team/src/index.ts) @@ -2607,7 +2607,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:245`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index aade7e7061..83a803f735 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -735,7 +735,7 @@ export interface Config { } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/experimental/tool-agent-team/src/index.ts:19`](../packages/experimental/tool-agent-team/src/index.ts) @@ -2609,7 +2609,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:245`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 09eecc6d42..9bc82f0bba 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4 -event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b +event-producer-consumer.md: c6a070ff66c579e5e8890c1f62d07e31b7b9eae1 +event-producer-consumer.zh.md: e061cb6acf9a6f951d6cad6a231ea65b1dc9217e diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2239c9786f..c6a070ff66 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), `tool-agent-team`, [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e43d48e2ee..e061cb6acf 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -18,7 +18,7 @@ | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), `tool-agent-team`, [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index fef7511422..96577399bd 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: 6e13fddcf0a15a396498f167d78ad8f324a39ec4 -system-prompt.zh.md: da3789a82d94316e5dbedbd05f291bce71b67807 +system-prompt.md: 516e3347c06882bfd3ff42638d77a2acb3aca753 +system-prompt.zh.md: 611ea0e9bdd81585efd7106012edd3d17e7bdd8e diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 6e13fddcf0..516e3347c0 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -80,8 +80,6 @@ interface PromptContext { readonly name: string /** Contexts are joined in ascending order. */ readonly order: number - /** Reject assembly when runtime context is suppressed and this contribution is effective. */ - readonly required?: boolean /** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */ readonly text: string | ((context: AssembleContext) => string) } @@ -169,7 +167,6 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. * @returns the post-waterfall assembly with any complete prompt enforced. - * @throws when runtime context is suppressed but an effective contribution requires it. */ async assemble(context: AssembleContext = {}): Promise ``` diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index da3789a82d..611ea0e9bd 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -80,8 +80,6 @@ interface PromptContext { readonly name: string /** Contexts are joined in ascending order. */ readonly order: number - /** Reject assembly when runtime context is suppressed and this contribution is effective. */ - readonly required?: boolean /** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */ readonly text: string | ((context: AssembleContext) => string) } @@ -169,7 +167,6 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. * @returns the post-waterfall assembly with any complete prompt enforced. - * @throws when runtime context is suppressed but an effective contribution requires it. */ async assemble(context: AssembleContext = {}): Promise ``` diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index ee059c0b91..afa3965820 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: 2fd4d93e75e6d1a6fcf2a3dd26620ab9c1d6e864 -README.zh.md: 2eff7d18e91373f456a7d2b6a872d3a657d3ce32 +README.md: e98c1be171d7dbb16cabf1289a20be8922e5076b +README.zh.md: 2408ec1dbb730bd5f085f3467a5359f5900efebc diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 2fd4d93e75..e98c1be171 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -77,7 +77,7 @@ Tool-schema providers are evaluated per assembly and contribute the model-visibl ### Suppress runtime context -`suppressRuntimeContext()` rejects assembly when an effective context is required; otherwise it removes every dynamic runtime-context contribution for the calling scope without disabling the services that own the underlying facts; multiple suppressors compose and the effect restores context when none remains. +`suppressRuntimeContext()` removes every dynamic runtime-context contribution for the calling scope without disabling the services that own the underlying facts; multiple suppressors compose and the effect restores context when none remains. ----- @@ -132,7 +132,7 @@ The package-level contract is enough for most consumers; read these when you nee #### What the model sees -First-party sections render the harness identity, deployment persona prefix (including the model-name introduction), reusable instructions (including the generated tools SDK and structured-output guidance), then the environment-bearing suffix: harness source (`10000`), Web surface (`10100`), and deployment persona suffix (`10200`). External section orders and assembly listeners remain authoritative. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete — that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. The rendered prompt reaches the model as a system-role message of derived history — surface node 0, or the latest system node after an in-history update — neither the loop request nor `request/header` carries a separate `system` field. If the complete rendering is empty, the loop clears every active system node through logged empty replacements, so no older prompt remains in model history. Ordered dynamic contexts are separate from sections and become sourced user-role snapshots only when present; `includeRuntimeContext: false` or a scoped suppressor removes them all, unless an effective contribution declares `required: true`, which rejects assembly before evaluating context providers. Scoped overrides determine the effective requirement; assembly listeners retain their authority to edit contexts. +First-party sections render the harness identity, deployment persona prefix (including the model-name introduction), reusable instructions (including the generated tools SDK and structured-output guidance), then the environment-bearing suffix: harness source (`10000`), Web surface (`10100`), and deployment persona suffix (`10200`). External section orders and assembly listeners remain authoritative. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete — that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. The rendered prompt reaches the model as a system-role message of derived history — surface node 0, or the latest system node after an in-history update — neither the loop request nor `request/header` carries a separate `system` field. If the complete rendering is empty, the loop clears every active system node through logged empty replacements, so no older prompt remains in model history. Ordered dynamic contexts are separate from sections and become sourced user-role snapshots only when present; `includeRuntimeContext: false` or a scoped suppressor removes them all. ##### Harness identity diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 2eff7d18e9..2408ec1dbb 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -77,7 +77,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd) ### 抑制 runtime 上下文 -`suppressRuntimeContext()` 在存在有效的必需上下文时使组装失败,否则移除调用作用域的所有动态 runtime 上下文贡献,但不禁用拥有底层事实的服务;多个抑制器独立组合,当不再存在抑制器时该 effect 会恢复上下文。 +`suppressRuntimeContext()` 移除调用作用域的所有动态 runtime 上下文贡献,但不禁用拥有底层事实的服务;多个抑制器独立组合,当不再存在抑制器时该 effect 会恢复上下文。 ----- @@ -132,7 +132,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd) #### 模型看到什么 -第一方段落依次渲染 harness 身份、部署 persona 前缀(含模型名称介绍)、可复用指令(包括生成的工具 SDK 和结构化输出指导),最后是携带环境信息的后缀:harness 源码(`10000`)、Web 表层(`10100`)和部署 persona 后缀(`10200`)。外部段落的顺序与组装监听器仍决定其最终结果。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段与变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete——此时该确切段会成为完整的系统提示词,而 waterfall 得到的上下文、工具与变量保持不变。渲染后的提示词作为派生历史中的 system 角色消息——surface 第 0 号节点,或历史内更新之后最新的系统节点——到达模型;循环请求与 `request/header` 均不含单独的 `system` 字段。完整渲染结果为空时,循环通过有日志记录的空内容替换清除所有生效的系统节点,模型历史不再保留任何旧提示词。有序动态上下文与段分离,只在存在时才会成为带来源的 user 角色快照;`includeRuntimeContext: false` 或带作用域的抑制器会移除全部这类上下文,除非有效贡献声明了 `required: true`,此时组装会在执行上下文提供方之前失败。作用域覆盖决定最终生效的必需标记;组装监听器仍可编辑上下文。 +第一方段落依次渲染 harness 身份、部署 persona 前缀(含模型名称介绍)、可复用指令(包括生成的工具 SDK 和结构化输出指导),最后是携带环境信息的后缀:harness 源码(`10000`)、Web 表层(`10100`)和部署 persona 后缀(`10200`)。外部段落的顺序与组装监听器仍决定其最终结果。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段与变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete——此时该确切段会成为完整的系统提示词,而 waterfall 得到的上下文、工具与变量保持不变。渲染后的提示词作为派生历史中的 system 角色消息——surface 第 0 号节点,或历史内更新之后最新的系统节点——到达模型;循环请求与 `request/header` 均不含单独的 `system` 字段。完整渲染结果为空时,循环通过有日志记录的空内容替换清除所有生效的系统节点,模型历史不再保留任何旧提示词。有序动态上下文与段分离,只在存在时才会成为带来源的 user 角色快照;`includeRuntimeContext: false` 或带作用域的抑制器会移除全部这类上下文。 ##### harness 身份 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index e5b11ad738..d83cc4d216 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -79,8 +79,6 @@ export interface PromptContext { readonly name: string /** Contexts are joined in ascending order. */ readonly order: number - /** Reject assembly when runtime context is suppressed and this contribution is effective. */ - readonly required?: boolean /** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */ readonly text: string | ((context: AssembleContext) => string) } @@ -162,7 +160,6 @@ const CONTEXT_ORDERS = { SANDBOX_POLICY: 110, APPROVAL_POLICY: 115, SUBAGENT_DELEGATION: 120, - TEAM_IDENTITY: 130, } as const /** Name of a centrally allocated runtime-context position. */ @@ -550,7 +547,6 @@ export class SystemPrompt extends Service { * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. * @returns the post-waterfall assembly with any complete prompt enforced. - * @throws when runtime context is suppressed but an effective contribution requires it. */ // Keep configuration failures on the declared asynchronous error path. async assemble(context: AssembleContext = {}): Promise { @@ -572,13 +568,6 @@ export class SystemPrompt extends Service { // Scoped sections shadow globals before the deterministic order sort. const sectionByName = this.layers.merge(scope, layer => layer.sections) const contextByName = this.layers.merge(scope, layer => layer.contexts) - if (runtimeContextSuppressed) { - for (const entry of contextByName.values()) { - if (entry.required) { - throw new Error(`required runtime context "${entry.name}" cannot be suppressed; enable runtime context or remove the requiring plugin`) - } - } - } // Validate order against pre-restriction names while collecting visible schemas. const providers = [ ...this.layers.global.toolProviders.values(), diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 8f8c0bb75e..2722919c68 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -143,33 +143,6 @@ describe('scoped cache-safe context', () => { .toContain('global policy') }) - it('uses the effective scoped requirement and restores it with suppression disposal', async () => { - const ctx = await mount() - const scope = await mintScope(ctx, 'required-context') - const key = scopeKeyOf(scope) - ctx.systemPrompt.context({ name: 'identity', order: 1, required: true, text: 'global identity' }) - const optional = scope.ctx.systemPrompt.context({ name: 'identity', order: 1, text: 'optional identity' }) - const suppress = scope.ctx.systemPrompt.suppressRuntimeContext() - expect((await ctx.systemPrompt.assemble({ scope: key })).contexts).toEqual([]) - expect(renderContextSnapshot(await ctx.systemPrompt.assemble())).toContain('global identity') - optional() - await expect(ctx.systemPrompt.assemble({ scope: key })).rejects.toThrow('required runtime context "identity"') - suppress() - expect(renderContextSnapshot(await ctx.systemPrompt.assemble({ scope: key }))).toContain('global identity') - await ctx.fiber.dispose() - }) - - it('rejects a required scoped override of an optional global context', async () => { - const ctx = await mount() - const scope = await mintScope(ctx, 'scoped-required') - ctx.systemPrompt.context({ name: 'identity', order: 1, text: 'optional global' }) - scope.ctx.systemPrompt.context({ name: 'identity', order: 1, required: true, text: 'required scoped' }) - ctx.systemPrompt.suppressRuntimeContext() - expect((await ctx.systemPrompt.assemble()).contexts).toEqual([]) - await expect(ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })).rejects.toThrow('required runtime context "identity"') - await ctx.fiber.dispose() - }) - it('suppresses all context for one scope and restores it when disposed', async () => { const ctx = await mount() const scope = await mintScope(ctx, 'suppressed-context') diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index f05497c5cc..8e60ea2bec 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -153,21 +153,6 @@ describe('SystemPrompt', () => { expect(providerCalls).toBe(0) }) - it('rejects globally suppressed required context before evaluating its provider', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt, { includeRuntimeContext: false }) - let providerCalls = 0 - const dispose = ctx.systemPrompt.context({ - name: 'identity', order: 0, required: true, - text: () => `identity ${++providerCalls}`, - }) - await expect(ctx.systemPrompt.assemble()).rejects.toThrow('required runtime context "identity" cannot be suppressed') - expect(providerCalls).toBe(0) - dispose() - expect((await ctx.systemPrompt.assemble()).contexts).toEqual([]) - await ctx.fiber.dispose() - }) - it('tolerates a schema-bypassing direct construction (persona omitted)', async () => { // ctx.plugin validates + defaults the config first; a direct construction // skips the schema, so the ctor's `?? ''` narrowing is what fires. diff --git a/packages/experimental/tool-agent-team/README.i18n.yaml b/packages/experimental/tool-agent-team/README.i18n.yaml index 74965304bb..d0af48d288 100644 --- a/packages/experimental/tool-agent-team/README.i18n.yaml +++ b/packages/experimental/tool-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/tool-agent-team/README.md -README.md: 2af62042a48bd644fd42e887a6a2e83ed6758e43 -README.zh.md: 7b3c3fe593d1cf2b64dd926fd26ec51b38367d99 +README.md: 49aa13c550d62810690668a66f574fbe38bed5c9 +README.zh.md: 607fab9a799611aaa55c7061ec9c226f9ade0c8c diff --git a/packages/experimental/tool-agent-team/README.md b/packages/experimental/tool-agent-team/README.md index 2af62042a4..49aa13c550 100644 --- a/packages/experimental/tool-agent-team/README.md +++ b/packages/experimental/tool-agent-team/README.md @@ -125,15 +125,15 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -One shared system policy states the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. The nine Team schemas from `spawn_teammate` through `team_task_update` appear only in Team member scopes. The current role/name/Team id appears in a `` inside a durable runtime-context user message after retained history, before model execution. Team identity requires runtime context; suppressing it rejects request assembly. +One shared system policy states the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. All nine Team schemas are identical for Leads and teammates; execution enforces Lead-only operations. Before a teammate's first task, a durable user message states `\nYou are teammate "".\n`. Leads receive no identity reminder unless an ordinary fork inherits one from a teammate; that fork receives `You are the Team Lead.` instead. Identity reminders contain no Team id and work when runtime context is disabled. #### Token effect -Fixed policy and schema cost on every Team member request. Identity contributes to the runtime snapshot on first use, after an identity change, or after compaction removes that snapshot; cold recovery reuses an unchanged retained snapshot. Tool calls add compact JSON roster, task, wait, or receipt results. Peer content is retained by the Team domain in the target's history. +Fixed policy and schema cost on every Team member request. A retained identity reminder is reused across steps and cold recovery; if compaction removes it, the plugin appends it before the next admitted step. Tool calls add compact JSON roster, task, wait, or receipt results. Peer content is retained by the Team domain in the target's history. #### KV Cache effect -With the same provider/model, shared system policy, and tool schemas, a fork retains the parent request prefix and appends its current identity. Ordinary Session forks also append a new Team id. Tool results and peer messages append after the reusable request prefix. Sessions recorded with identity inside the system prompt can change that prefix on their first request under this layout; actual provider cache hits remain best-effort. +With the same provider/model, shared system policy, and tool schemas, a fork retains the parent request prefix and appends its reminder and task. Tool results and peer messages append after the reusable request prefix. Sessions recorded with identity inside the system prompt can change that prefix on their first request under this layout; actual provider cache hits remain best-effort. ## Known Limitations and Deferred Work diff --git a/packages/experimental/tool-agent-team/README.zh.md b/packages/experimental/tool-agent-team/README.zh.md index 7b3c3fe593..607fab9a79 100644 --- a/packages/experimental/tool-agent-team/README.zh.md +++ b/packages/experimental/tool-agent-team/README.zh.md @@ -125,15 +125,15 @@ member scope 上的一个 `team:policy` 段落教每个成员自己的角色与 #### 模型看到什么 -一段共享 system 策略会说明显式 delegation 要求、共享 cwd 行为、文件 stale-version 恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。`spawn_teammate` 到 `team_task_update` 的九个 Team schema 只出现在 Team member scope。当前 role/name/Team id 放在持久化运行时上下文 user 消息的 `` 中,位于保留历史之后、模型执行之前。Team 身份要求启用运行时上下文;禁用会使请求组装失败。 +一段共享 system 策略会说明显式 delegation 要求、共享 cwd 行为、文件 stale-version 恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。Lead 与 teammate 的全部九个 Team schema 相同;执行时检查仅限 Lead 的操作权限。teammate 的首次任务前,一条持久化 user 消息声明 `\nYou are teammate "".\n`。Lead 不接收身份提醒,除非普通 fork 继承了 teammate 的提醒;该 fork 会收到 `You are the Team Lead.`。身份提醒不含 Team id,禁用运行时上下文时也能生效。 #### Token 影响 -每次 Team member 请求都有固定策略与 schema 成本。首次使用、身份变化或压缩移除快照时,身份会进入运行时快照;冷恢复复用保留且未变化的快照。工具调用会增加紧凑 JSON roster、task、wait 或 receipt 结果。Peer 内容由 Team 领域保留在 target 历史中。 +每次 Team member 请求都有固定策略与 schema 成本。保留的身份提醒在后续步骤与冷恢复中复用;若压缩移除了提醒,插件会在下一步获准执行前追加它。工具调用会增加紧凑 JSON roster、task、wait 或 receipt 结果。Peer 内容由 Team 领域保留在 target 历史中。 #### KV Cache 影响 -provider/model、共享 system 策略和工具 schema 相同时,fork 保留父请求前缀并追加当前身份。普通 Session fork 也会追加新的 Team id。工具结果与 peer 消息追加在可复用请求前缀之后。原先在 system prompt 中记录身份的 Session,首次使用此布局请求时可能改变该前缀;提供方实际缓存命中仍为尽力而为。 +provider/model、共享 system 策略和工具 schema 相同时,fork 保留父请求前缀并追加提醒与任务。工具结果与 peer 消息追加在可复用请求前缀之后。原先在 system prompt 中记录身份的 Session,首次使用此布局请求时可能改变该前缀;提供方实际缓存命中仍为尽力而为。 ## 已知限制与延期工作 diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index 7737876026..fcf31b9e2f 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -33,7 +33,8 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +52,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^" } } diff --git a/packages/experimental/tool-agent-team/src/index.ts b/packages/experimental/tool-agent-team/src/index.ts index d5dce434dd..4186894b97 100644 --- a/packages/experimental/tool-agent-team/src/index.ts +++ b/packages/experimental/tool-agent-team/src/index.ts @@ -3,6 +3,8 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { TeamTaskId } from '@deepseek-ai/dsh-experimental-agent-team' import type { TeamMemberView } from '@deepseek-ai/dsh-experimental-agent-team' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -155,6 +157,38 @@ function callingAgent(agent: Agent | undefined, toolName: string): Agent { return agent } +/** Identify the plugin-owned reminder without interpreting user-authored text. */ +function isIdentity(event: SessionEvent): event is SessionEvent<'user/message'> { + return event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === name +} + +/** Keep member identity after inherited history and restore it if compaction removes it. */ +function installIdentity(agent: Agent, ctx: Context): () => void { + const membership = ctx.agentTeams.membership(agent) + // Ordinary forks of teammates become Leads even when the inherited reminder + // is shadowed by a summary. Their full inherited log still identifies it. + const inheritedIdentity = agent.session.snapshotEvents().slice(0, agent.session.inheritedEventCount).some(isIdentity) + if (membership.role === 'lead' && !inheritedIdentity) return () => {} + const text = membership.role === 'teammate' + ? `\nYou are teammate "${membership.name}".\n` + : '\nYou are the Team Lead.\n' + return agent.ctx.on('agent/pre-step', async ({ messages, step, signal }, next) => { + const decision = await next() + if (decision.kind === 'reject' || signal.aborted) return decision + if (decision.messages.length === 0 && (step === 1 || messages.length > 0)) return decision + const retained = agent.session.surface.nodes.map(seq => agent.session.eventAt(seq)).findLast(event => + event !== undefined && isIdentity(event)) + if (retained !== undefined && isIdentity(retained) + && retained.data.content.some(block => block.type === 'text' && block.text === text)) return decision + const reminder = createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'plugin', plugin: name, form: 'snapshot', sections: [{ name: 'team:identity', text }] }, + }) + return { ...decision, messages: [reminder, ...decision.messages] } + }, { prepend: true }) +} + /** Register the complete Team tool set in one exact Agent scope. */ function install(agent: Agent, ctx: Context, config: Required): () => void { const scoped = agent.ctx @@ -166,15 +200,7 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo order: scoped.systemPrompt.getSectionOrder('TEAM_POLICY'), text: POLICY, })) - register(scoped.systemPrompt.context({ - name: 'team:identity', - order: scoped.systemPrompt.getContextOrder('TEAM_IDENTITY'), - required: true, - text: () => { - const membership = ctx.agentTeams.membership(agent) - return `\nYour Team role is ${membership.role}; your Team name is ${membership.name}; Team id is ${membership.id}.\n` - }, - })) + register(installIdentity(agent, ctx)) register(scoped.tools.register(defineTool({ name: 'spawn_teammate', 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 d0c4684e1b..8358c83b33 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -18,7 +18,7 @@ import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-pro import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { serializeRequest } from '../../../llm/llm-deepseek/src/serialize.ts' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import TeamService from '../../agent-team/src/index.ts' import * as toolTeam from '../src/index.ts' @@ -145,7 +145,7 @@ describe('dsh-tool-team', () => { expect(leadPrompt).toContain('Task readiness never starts an owner') expect(leadPrompt).toContain('returns noProgress immediately') expect(leadPrompt).not.toContain('Your Team role') - expect(renderContextSnapshot(leadAssembly)).toContain('Your Team role is lead') + expect(renderContextSnapshot(leadAssembly)).toBe('') const spawned = await execute(ctx, lead, 'spawn_teammate', { name: 'tool-worker', @@ -159,7 +159,9 @@ describe('dsh-tool-team', () => { expect(childAssembly.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) .toEqual(TOOL_NAMES) expect(renderPrompt(childAssembly)).toBe(leadPrompt) - expect(renderContextSnapshot(childAssembly)).toContain('Your Team role is teammate; your Team name is tool-worker') + expect(renderContextSnapshot(childAssembly)).not.toContain('team:identity') + expect(child.session.deriveMessages().some(message => message.content.some(block => + block.type === 'text' && block.text === '\nYou are teammate "tool-worker".\n'))).toBe(true) const initialPrompt = child.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') expect(initialPrompt?.type === 'user/message' @@ -199,9 +201,11 @@ describe('dsh-tool-team', () => { } else { expect(JSON.stringify(childRequest.messages)).not.toContain('Parent task') } - const latest = childRequest.messages.at(-1) + const taskIndex = childRequest.messages.findIndex(message => message.content === 'Review the work') + const latest = childRequest.messages[taskIndex - 1] + expect(taskIndex).toBeGreaterThan(0) expect(latest?.role).toBe('user') - expect(latest?.content).toContain(`\nYour Team role is teammate; your Team name is reviewer; Team id is ${lead.id}.\n`) + expect(latest?.content).toContain('\nYou are teammate "reviewer".\n') await using persisted = await ctx.sessionPersistence.open(childId, 'read') const { events } = await persisted.read() expect(events.some(event => event.type === 'user/message' @@ -210,7 +214,7 @@ describe('dsh-tool-team', () => { && event.data.source.sections.some(section => section.name === 'team:identity'))).toBe(true) }) - it('starts an ordinary fork with a new Lead identity without replacing the inherited request prefix', async () => { + it('keeps an ordinary Lead fork free of identity reminders without replacing the inherited prefix', async () => { const { ctx, lead, adapter } = await setup([textResponse('parent answer'), textResponse('fork answer')]) await runTurn(lead, 'Parent task') const seed = lead.session.snapshotEvents() @@ -228,45 +232,125 @@ describe('dsh-tool-team', () => { const childRequest = serializeRequest(adapter.requests[1]!) expect(childRequest.tools).toEqual(parentRequest.tools) expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages) - expect(childRequest.messages.at(-1)?.content).toContain(`Your Team role is lead; your Team name is lead; Team id is ${childId}.`) + expect(childRequest.messages.at(-1)?.content).toBe('Continue independently') + expect(JSON.stringify(childRequest.messages)).not.toContain('system-reminder') expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) expect(ctx.agentTeams.listMembers(handle.agent).map(member => member.name)).toEqual(['lead']) await handle.dispose() }) - it('reissues identity after its retained snapshot is compacted away', async () => { - const { lead, adapter } = await setup([textResponse('first'), textResponse('second'), textResponse('third')]) - await runTurn(lead, 'First task') - await runTurn(lead, 'Second task') - const identityEvents = () => lead.session.snapshotEvents().filter(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' && event.data.source.form === 'snapshot' - && event.data.source.sections.some(section => section.name === 'team:identity')) - expect(identityEvents()).toHaveLength(1) - const identity = identityEvents()[0]! - lead.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Compacted earlier context.' }], - source: { kind: 'plugin', plugin: 'test-compaction' }, - }), { - surfaceOp: { op: 'replace', startSeq: identity.seq, endSeq: identity.seq }, - sourceEventSeqs: [identity.seq], + it('reissues teammate identity after compaction removes its retained reminder', async () => { + const { ctx, lead, adapter } = await setup([ + toolCallResponse('first-list', 'list_agents', {}), + toolCallResponse('second-list', 'list_agents', {}), + textResponse('done'), + ]) + ctx.on('agent/pre-step', async ({ agent, step }, next) => { + if (step === 3) { + const identity = agent.session.snapshotEvents().find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === toolTeam.name) + if (identity === undefined) throw new Error('expected initial teammate reminder') + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Compacted earlier context.' }], + source: { kind: 'plugin', plugin: 'test-compaction' }, + }), { + surfaceOp: { op: 'replace', startSeq: identity.seq, endSeq: identity.seq }, + sourceEventSeqs: [identity.seq], + }) + } + return next() }) - await runTurn(lead, 'Third task') - expect(identityEvents()).toHaveLength(2) - const refreshed = identityEvents()[1]! - expect(refreshed.type).toBe('user/message') - if (refreshed.type !== 'user/message') throw new Error('expected identity message') - expect(adapter.requests[2]!.messages.at(-1)?.content).toEqual(refreshed.data.content) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'reviewer', description: 'review', prompt: 'Review the work', + }) + const childId = spawnedChildId(spawned) + await waitNoAgent(ctx, childId) + const reminder = '\nYou are teammate "reviewer".\n' + const first = serializeRequest(adapter.requests[0]!).messages + expect(first[first.findIndex(message => message.content === 'Review the work') - 1]?.content).toBe(reminder) + expect(adapter.requests[1]!.messages.filter(message => message.content.some(block => + block.type === 'text' && block.text === reminder))).toHaveLength(1) + expect(serializeRequest(adapter.requests[2]!).messages.at(-1)?.content).toBe(reminder) + await using persisted = await ctx.sessionPersistence.open(childId, 'read') + const { events } = await persisted.read() + expect(events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === toolTeam.name)).toHaveLength(2) }) - it('rejects a suppressed Team identity before sending any model request', async () => { - const { lead, adapter } = await setup([]) - lead.ctx.systemPrompt.suppressRuntimeContext() - await runTurn(lead, 'Do work') - const end = lead.session.snapshotEvents().at(-1) - expect(end?.type).toBe('turn/end') - if (end?.type !== 'turn/end' || end.data.reason.kind !== 'error') throw new Error('expected failed turn') - expect(end.data.reason.error.message).toContain('required runtime context "team:identity"') - expect(adapter.requests).toEqual([]) + it.each([false, true])('corrects inherited teammate identity on an ordinary fork, compacted=%s', async (compacted) => { + const { ctx, lead, adapter } = await setup([textResponse('review done'), textResponse('independent work')]) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'reviewer', description: 'review', prompt: 'Review the work', + }) + const teammateId = spawnedChildId(spawned) + await waitNoAgent(ctx, teammateId) + await using persisted = await ctx.sessionPersistence.open(teammateId, 'read') + const { events: seed } = await persisted.read() + const handle = await ctx.agents.create({ + sessionId: SessionId('fork-from-teammate'), + seed, + inheritedEventCount: SessionLogOffset(seed.length), + meta: { parentSession: teammateId, isSeeded: true }, + agentOptions: { provider: 'mock', model: 'mock' }, + signal: SIGNAL, + }) + try { + if (compacted) { + const identity = seed.find(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === toolTeam.name) + if (identity === undefined) throw new Error('expected inherited identity') + handle.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'The reviewer completed its task.' }], + source: { kind: 'plugin', plugin: 'test-compaction' }, + }), { + surfaceOp: { op: 'replace', startSeq: identity.seq, endSeq: identity.seq }, + sourceEventSeqs: [identity.seq], + }) + } + await runTurn(handle.agent, 'Continue independently') + const request = serializeRequest(adapter.requests.find(request => request.sessionId === handle.agent.id)!) + const taskIndex = request.messages.findIndex(message => message.content === 'Continue independently') + expect(request.messages[taskIndex - 1]?.content).toBe('\nYou are the Team Lead.\n') + expect(taskIndex).toBeGreaterThan(0) + expect(ctx.agentTeams.membership(handle.agent).role).toBe('lead') + expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) + } finally { + await handle.dispose() + } + }) + + it.each(['reject', 'empty', 'abort'] as const)('does not revive a teammate step after %s', async (mode) => { + const { ctx, lead, adapter } = await setup([]) + ctx.on('agent/pre-step', async ({ agent }, next) => { + const decision = await next() + if (agent === lead) return decision + if (mode === 'reject') return { kind: 'reject' } + if (mode === 'abort') agent.cancel({ kind: 'user' }) + return { kind: 'enter', messages: [] } + }) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'reviewer', description: 'review', prompt: 'Review the work', + }) + expect(spawned.isError).toBe(true) + const childId = SessionId(ctx.agentTeams.listMembers(lead).find(member => member.name === 'reviewer')!.id) + await waitNoAgent(ctx, childId) + expect(adapter.requests.filter(request => request.sessionId === childId)).toEqual([]) + await using persisted = await ctx.sessionPersistence.open(childId, 'read') + const { events } = await persisted.read() + expect(events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === toolTeam.name)).toEqual([]) + }) + + it('keeps teammate reminders when runtime context is suppressed', async () => { + const { ctx, lead, adapter } = await setup([textResponse('worker done')]) + ctx.systemPrompt.suppressRuntimeContext() + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'reviewer', description: 'review', prompt: 'Review the work', + }) + expect(spawned.isError, text(spawned)).toBe(false) + await waitNoAgent(ctx, spawnedChildId(spawned)) + expect(serializeRequest(adapter.requests[0]!).messages.at(-2)?.content) + .toBe('\nYou are teammate "reviewer".\n') }) it('returns actionable no-progress output and renders structured wait cancellation', async () => { @@ -533,9 +617,9 @@ describe('dsh-tool-team', () => { const resumed = await waitRunning(ctx, childId) expect((await assembly(ctx, resumed)).tools.map(schema => schema.name) .filter(name => TOOL_NAMES.includes(name)).sort()).toEqual(TOOL_NAMES) - expect(renderContextSnapshot(await assembly(ctx, resumed))).toContain('Your Team role is teammate; your Team name is cold-worker') + expect(renderContextSnapshot(await assembly(ctx, resumed))).not.toContain('You are teammate') const childRequests = () => adapter.requests.filter(request => request.messages.some(message => message.role === 'user' - && message.content.some(block => block.type === 'text' && block.text.includes('your Team name is cold-worker')))) + && message.content.some(block => block.type === 'text' && block.text.includes('You are teammate "cold-worker".')))) await vi.waitFor(() => { expect(childRequests()).toHaveLength(2) }) const firstMessages = childRequests()[0]!.messages expect(childRequests()[1]!.messages.slice(0, firstMessages.length)).toEqual(firstMessages) diff --git a/packages/experimental/tool-agent-team/tsconfig.json b/packages/experimental/tool-agent-team/tsconfig.json index d4f77f93e5..aa04ed3338 100644 --- a/packages/experimental/tool-agent-team/tsconfig.json +++ b/packages/experimental/tool-agent-team/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../../core/session" }, { "path": "../../core/system-prompt" }, { "path": "../../core/tools" }, + { "path": "../../llm/llm" }, { "path": "../agent-team" } ] } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 775efed638..4606b99848 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2448,7 +2448,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Assemble global and scoped providers, detach tool parameters, apply canonical ordering, then run the assembly waterfall. Scoped sections and variables shadow globals. The returned waterfall value is authoritative except that an effective complete section is restored afterwards as the sole prompt section.', parameters: [{ name: 'context', description: 'the optional scope and plugin-defined assembly fields.' }], returns: 'the post-waterfall assembly with any complete prompt enforced.', - throws: ['when runtime context is suppressed but an effective contribution requires it.'], }, ], }, @@ -4819,7 +4818,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptContext', - declaration: 'export interface PromptContext {\n readonly name: string;\n readonly order: number;\n readonly required?: boolean;\n readonly text: string | ((context: AssembleContext) => string);\n}', + declaration: 'export interface PromptContext {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', }, { name: 'PromptContextOrderName', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcea481379..b49c18e0a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5564,6 +5564,9 @@ importers: '@deepseek-ai/dsh-experimental-agent-team': specifier: workspace:^ version: link:../agent-team + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope