From e06625d202ba53836a16865e0f779a44a85ec167 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:43:15 +0800 Subject: [PATCH 01/42] fix(web): switch running drafts to Send --- ...08-20-running-draft-primary-send.i18n.yaml | 6 +++ .../2026-08-20-running-draft-primary-send.md | 31 +++++++++++ ...026-08-20-running-draft-primary-send.zh.md | 31 +++++++++++ ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 +- .../2026-08-06-web-queue-steer-all-gesture.md | 2 +- ...26-08-06-web-queue-steer-all-gesture.zh.md | 2 +- apps/web/tests/live-interactions.e2e.ts | 35 ++++++++++--- .../running-draft.expected.md | 28 ++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/skeleton/InputBar.tsx | 8 +-- .../tests/input-bar.client.spec.tsx | 52 +++++++++++++++++-- 13 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md create mode 100644 apps/web/tests/snapshots/live-interactions/running-draft.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.i18n.yaml new file mode 100644 index 0000000000..5a1d90d7f1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/bug-fix/2026-08-20-running-draft-primary-send.md +2026-08-20-running-draft-primary-send.md: 79c8c3a74fc49a325b229324b0d96b2387308d3d +2026-08-20-running-draft-primary-send.zh.md: 180352810f57ecc04882322318f9708cf247a09c diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md new file mode 100644 index 0000000000..79c8c3a74f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md @@ -0,0 +1,31 @@ +# Agent Note: Running drafts take the primary Send action + +Status: implemented + +English | [中文](2026-08-20-running-draft-primary-send.zh.md) + +## Problem + +The ordinary Web composer remains editable while a Turn is running, and keyboard submission can queue or steer its draft. Its single primary pointer control nevertheless stayed on Stop for the entire Turn. A pointer user who entered a follow-up and activated that control stopped the current Turn instead of submitting the visible draft, so the control contradicted the composer's editable state and the user's current content. + +## Decision + +`InputBar` chooses the ordinary session's primary action from the running state, draft content, and owner block. An empty running composer shows Stop and routes it through the existing session cancellation callback. Non-whitespace text or at least one attachment changes that same control to Send; the click uses the existing Queue submission path. An owner-blocked running composer keeps Stop even when a retained draft exists, because the block disables both editing and submission. Clearing the draft or completing a successful submission restores Stop while the Turn remains active. Idle sessions continue to show Send, disabled while the draft is empty or submission is unavailable. + +The pointer action does not inherit the `ui-conversation.busyEnter` preference. That preference continues to choose Queue or Steer only for the two keyboard gestures. Continuable subagents retain independent Send and Stop controls, and one-shot subagents retain their read-only behavior. + +## Verification + +The `InputBar` component test covers empty, text, cleared, submitted, attachment-only, and owner-blocked running drafts, including Queue submission while the keyboard preference selects Steer. The keyless assembled Web scenario parks a real composed Turn in the replay adapter, captures the running draft with Send, clicks it through the Host Queue path, observes Stop return after the draft clears, removes the queued row, and then cancels the Turn. + +## Alternatives considered + +**Keep Stop for the whole running Turn.** This preserves immediate cancellation but leaves the visible editable draft without a pointer submission action and makes the primary control act against the content beside it. + +**Render Send and Stop simultaneously for every running session.** Continuable subagents need two independent operations because their cancellation route differs from continuation delivery. Ordinary sessions have one established primary seat; adding a permanent second control would spend more space and create a different hierarchy when the draft itself already identifies the immediate action. + +**Apply the busy-Enter preference to pointer Send.** A button labeled Send would silently change between Queue and Steer according to a keyboard preference. Keeping pointer submission on Queue preserves the existing explicit distinction and avoids an invisible mode on the button. + +## Consequences + +Pointer users can submit a follow-up without waiting for the active Turn or using a keyboard shortcut. An actionable draft occupies the single primary seat, so Stop returns after the draft is cleared or accepted rather than remaining simultaneously visible; an owner block returns that seat to Stop because the retained draft cannot be edited or submitted. Keyboard delivery selection, cancellation transport, and subagent controls are unchanged. Issue #2850 records the user-facing defect and acceptance boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md new file mode 100644 index 0000000000..180352810f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 运行中草稿取得主 Send 操作 + +Status: implemented + +[English](2026-08-20-running-draft-primary-send.md) | 中文 + +## 问题 + +普通 Web composer 在 Turn 运行期间仍可编辑,键盘提交也能把草稿送入 Queue 或 Steer。然而,其唯一的主指针控件会在整个 Turn 中一直保持 Stop。指针用户输入后续消息并激活该控件时,会停止当前 Turn,而不是提交眼前的草稿;该控件因而与 composer 的可编辑状态和用户当前内容相冲突。 + +## 决策 + +`InputBar` 根据运行状态、草稿内容和 owner block 选择普通会话的主操作。运行中的 composer 为空时显示 Stop,并通过既有会话取消回调执行。存在非空白文字或至少一个附件时,同一控件切换为 Send,点击后使用既有 Queue 提交路径。owner block 会禁用编辑与提交,因此即使保留了草稿,运行中的 composer 也保持 Stop。清空草稿或成功提交后,只要 Turn 仍在运行,就会恢复 Stop。空闲会话仍显示 Send;草稿为空或无法提交时,该按钮保持禁用。 + +指针操作不继承 `ui-conversation.busyEnter` 偏好。该偏好仍然只为两个键盘手势选择 Queue 或 Steer。可继续 subagent 保留相互独立的 Send 与 Stop 控件,one-shot subagent 保持只读行为。 + +## 验证 + +`InputBar` 组件测试覆盖运行中草稿的空白、文字、清空、提交成功、仅附件和 owner-blocked 状态,并证明键盘偏好选择 Steer 时,按钮提交仍使用 Queue。无密钥的组装 Web 场景通过 replay 适配器停住真实组合出的 Turn,捕获显示 Send 的运行中草稿,经 Host Queue 路径点击提交,在草稿清空后观察 Stop 恢复,移除 Queue 行,再取消该 Turn。 + +## 备选方案 + +**在整个运行中 Turn 保持 Stop。** 这样可以始终立即取消,但可见的可编辑草稿没有指针提交操作,主控件也会执行与相邻内容相反的动作。 + +**为每个运行中会话同时渲染 Send 与 Stop。** 可继续 subagent 需要两个独立操作,因为其取消路由与继续投递不同。普通会话已有单一主操作位置;永久增加第二个控件会占用更多空间,并在草稿本身已经指明当前操作时引入另一套层级。 + +**让指针 Send 采用 busy-Enter 偏好。** 标记为 Send 的按钮会随键盘偏好在 Queue 与 Steer 之间静默变化。保持指针提交始终使用 Queue,可以保留既有显式区分,避免按钮携带不可见模式。 + +## 影响 + +指针用户无需等待当前 Turn 结束或使用键盘快捷键,即可提交后续消息。可操作草稿会占用唯一的主操作位置,因此 Stop 会在草稿清空或被接纳后恢复,而不是同时显示;owner block 会让该位置恢复 Stop,因为保留的草稿无法编辑或提交。键盘投递选择、取消传输和 subagent 控件均不变。Issue #2850 记录用户可见缺陷与验收边界。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index b47b635768..e7612e322b 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.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-06-web-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76 -2026-08-06-web-queue-steer-all-gesture.zh.md: 1f65933ac430ca22ac8d6c78471a3bd08b421ad6 +2026-08-06-web-queue-steer-all-gesture.md: c51e837d37b61a73f9602445daea9fbdfc77a299 +2026-08-06-web-queue-steer-all-gesture.zh.md: b0009a7a51ebe89f61d1a4b995b1138a7247d266 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index e546f68647..c51e837d37 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -30,4 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee - **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. - **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. - **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. -- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly. +- **A send-button tooltip.** Rejected: the primary button is Stop in the empty-draft running window, which is also the only window where the whole-queue gesture is available. The placeholder occupies that exact window and can describe the keyboard action directly. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index 1f65933ac4..b0009a7a51 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -30,4 +30,4 @@ Status: implemented - **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 - **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 - **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 -- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 +- **发送按钮 tooltip。** 已拒绝:主按钮在空草稿的运行窗口内是 Stop,而这也正是整队列手势唯一可用的窗口。placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 85a7a57e62..0586a900c2 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -1,6 +1,7 @@ -// Web e2e scenarios: live-turn interactions — cancellation, error surfacing, -// transient-retry recovery, and retry exhaustion, all through the real -// composition and wire. The model adapter is dsh-llm-replay with override +// Web e2e scenarios: live-turn interactions — running-draft submission, +// cancellation, error surfacing, transient-retry recovery, and retry +// exhaustion, all through the real composition and wire. The model adapter is +// dsh-llm-replay with override // sidecars: `hang` (+ a readyFile marker) makes mid-stream cancel // deterministic by construction, `throw` entries express provider failures by // stable code, and `{ patches }` augmentation injects transient throws before @@ -29,11 +30,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// One golden pins the stable mid-turn loading state; the other four capture -// what the user is left looking at after cancel, after a non-retryable failure, -// after retry recovery, and after retry exhaustion. +// One golden pins the empty mid-turn loading state, one pins the sendable draft +// state, and the other four capture what remains after cancel, after a +// non-retryable failure, after retry recovery, and after retry exhaustion. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md') +const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md') @@ -44,6 +46,7 @@ const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-se // patch. Kept deliberately tool-free so the derived script is exactly one // model call. const PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.' +const RUNNING_DRAFT = 'Queue this follow-up while the current turn is running.' /** turn/end reasons observed, in order. */ function turnEndReasons(events: SessionEvent[]): string[] { @@ -147,6 +150,22 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { ).toBe(true) const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE) + + const input = page.locator('textarea').first() + await input.fill(RUNNING_DRAFT) + const send = page.getByRole('button', { name: 'Send message', exact: true }) + await send.waitFor({ timeout: 10_000 }) + expect(await page.getByRole('button', { name: 'Stop generating', exact: true }).count()).toBe(0) + const runningDraftSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(RUNNING_DRAFT_EXPECTED, runningDraftSnapshot, MODE) + await send.click() + await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('') + const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: RUNNING_DRAFT }) + await queuedRow.waitFor({ timeout: 10_000 }) + await page.getByRole('button', { name: 'Stop generating', exact: true }).waitFor({ timeout: 10_000 }) + await queuedRow.getByRole('button', { name: 'Remove queued message' }).click() + await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('button', { name: 'Stop generating' }).click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') @@ -281,8 +300,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md', - 'retry-exhausted.expected.md', + 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'running-draft.expected.md', + 'error-auth.expected.md', 'retry.expected.md', 'retry-exhausted.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/live-interactions/running-draft.expected.md b/apps/web/tests/snapshots/live-interactions/running-draft.expected.md new file mode 100644 index 0000000000..d4e15c0498 --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/running-draft.expected.md @@ -0,0 +1,28 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- status: Deep diving... +- textbox "Message the agent": Queue this follow-up while the current turn is running. +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a6102ddac0..85e96fbdca 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 4c9665b680fe1922770403d88a04dffc755481ad -README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635 +README.md: 08d1408bea404a80da491078aa013989835fccaf +README.zh.md: 874aea8865e6f74a64da2c249087bdad032d98d5 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4c9665b680..08d1408bea 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,6 +20,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +An ordinary running composer keeps Stop as its primary pointer action while its draft is empty or an owner block makes input unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. Keyboard Queue/Steer selection remains governed by the busy-Enter setting, while continuable subagents keep independent Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). + ## Temporary composer entries `conversation.composer` is a generic chain. Its complete owner currency is: diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8bf3db2cb1..874aea8865 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -20,6 +20,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +普通 composer 运行期间,草稿为空或 owner block 使输入不可用时,主指针操作保持为 Stop。可提交文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。键盘 Queue/Steer 选择仍由繁忙态 Enter 设置决定,可继续 subagent 则保留相互独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 + ## 临时 composer entry `conversation.composer` 是通用 chain,其完整 owner currency 为: diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 8bedb2d260..0a137c8010 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -554,10 +554,10 @@ export function InputBar({ if (el !== null) toggleCommandMenu?.(selectionOf(el)) } - // Ordinary sessions retain their primary Send/Stop toggle. A continuable - // child keeps Send as the primary action and exposes Stop independently so - // pointer users can queue follow-ups while its current turn is running. - const primaryStops = running && subagent === null + // An ordinary running session keeps Stop while the composer is empty or + // owner-blocked; an actionable draft gets the existing Queue action. A + // continuable child keeps Send primary and exposes Stop independently. + const primaryStops = running && subagent === null && (empty || blocked !== undefined) const interruptible = running && continuable const primaryLabel = primaryStops ? t('input.stop') : t('input.send') const onPrimary = (): void => { diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f36b1aa1c4..17064a97a5 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -64,6 +64,7 @@ interface BenchOptions { subagent?: Exclude disabled?: boolean inert?: boolean + blocked?: { readonly reason: string } workspacePickerOpen?: boolean onRequestWorkspace?: () => void promptError?: SessionSnapshot['promptError'] @@ -186,6 +187,7 @@ function bench(over?: BenchOptions) { renderSlot, variant: over?.variant ?? 'composer', ...(over?.inert === true ? { disabled: true } : {}), + ...(over?.blocked !== undefined ? { blocked: over.blocked } : {}), ...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}), ...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}), ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -196,7 +198,9 @@ function bench(over?: BenchOptions) { } const view = render() const textarea = view.container.querySelector('textarea')! + const sendableDraft = (over?.draft?.trim() ?? '') !== '' || (over?.attachments?.length ?? 0) > 0 const primaryStops = over?.running === true && over.subagent === undefined + && (!sendableDraft || over.blocked !== undefined) const button = view.container.querySelector( `button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`, )! @@ -606,15 +610,53 @@ describe('Enter semantics', () => { }) describe('running and lock semantics', () => { - it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => { - const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' }) + it('running switches the primary between Stop and Queue Send with the draft', async () => { + const { textarea, button, stop, sink } = bench({ running: true, busyEnter: 'steer' }) expect(textarea.disabled).toBe(false) - fireEvent.change(textarea, { target: { value: '排队消息2' } }) - fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) + + fireEvent.change(textarea, { target: { value: '排队消息' } }) + expect(button.getAttribute('aria-label')).toBe('发送消息') + fireEvent.change(textarea, { target: { value: ' ' } }) + expect(button.getAttribute('aria-label')).toBe('停止生成') + fireEvent.change(textarea, { target: { value: '排队消息2' } }) + expect(button.getAttribute('aria-label')).toBe('发送消息') + fireEvent.click(button) + expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) + await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') }) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('running treats an attachment-only draft as Send', async () => { + const attachment = { + kind: 'image' as const, + id: 'draft-1' as DraftAttachmentId, + file: new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }), + previewUrl: 'blob:pixel', + } + const { button, sink } = bench({ running: true, attachments: [attachment] }) + expect(button.getAttribute('aria-label')).toBe('发送消息') + fireEvent.click(button) + expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) + await vi.waitFor(() => { expect(button.getAttribute('aria-label')).toBe('停止生成') }) + }) + + it('running blocked composer keeps Stop with a retained draft', () => { + const { button, sink, stop, textarea } = bench({ + running: true, + draft: '保留的草稿', + blocked: { reason: '请选择可用模型' }, + placeholder: '请选择可用模型', + }) + expect(textarea.disabled).toBe(true) + expect(textarea.placeholder).toBe('请选择可用模型') + expect(button.getAttribute('aria-label')).toBe('停止生成') + expect(button.disabled).toBe(false) + fireEvent.click(button) + expect(stop).toHaveBeenCalledTimes(1) + expect(sink).not.toHaveBeenCalled() }) it('running plain Enter follows the busy-state Steer preference', () => { From 6434b894c22ca6a65d35dfd725ab0be00684013b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 12:02:38 +0800 Subject: [PATCH 02/42] chore(attachment): start issue-2885 image codec work From bd4e4173e71cdd90deb104788addc3d1b54f5228 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 21 Aug 2026 14:49:11 +0800 Subject: [PATCH 03/42] feat(attachment): expose model-readable image paths --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 6 +- ...08-20-unified-image-request-pipeline.zh.md | 6 +- ...08-21-model-readable-image-paths.i18n.yaml | 6 + .../2026-08-21-model-readable-image-paths.md | 37 ++++ ...026-08-21-model-readable-image-paths.zh.md | 37 ++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 21 ++- docs/subsystems/attachment.zh.md | 21 ++- examples/acp-agent/tests/acp.snapshot.ts | 40 ++-- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 4 +- .../attachment/attachment-local/README.zh.md | 4 +- .../attachment/attachment-local/src/index.ts | 22 ++- .../attachment/attachment-local/src/store.ts | 19 +- .../attachment-local/tests/index.spec.ts | 30 ++- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 6 +- packages/attachment/attachment/README.zh.md | 6 +- packages/attachment/attachment/src/index.ts | 12 ++ packages/attachment/attachment/src/types.ts | 8 + .../attachment/attachment/tests/index.spec.ts | 6 + .../extensions/tool-cordis/src/api-catalog.ts | 12 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/README.zh.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 16 +- packages/llm/llm-deepseek/src/index.ts | 17 +- packages/llm/llm-deepseek/src/serialize.ts | 11 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 17 +- .../llm-deepseek/tests/dynamic-config.spec.ts | 2 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 51 +++++- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/context.ts | 6 +- packages/llm/llm-pi-ai/tests/context.spec.ts | 52 +++++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 6 +- packages/llm/llm/README.zh.md | 6 +- packages/llm/llm/src/content.ts | 94 ++++++---- packages/llm/llm/tests/content.spec.ts | 173 ++++++++++++++++-- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + 48 files changed, 643 insertions(+), 187 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md create mode 100644 .agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 6a379a3532..cca3335220 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: ada15d540539977c631e359ffdc7baa4fa84c78e -2026-08-20-unified-image-request-pipeline.zh.md: 85c9a1f837d82cba2bc62b30402433f50c873cbe +2026-08-20-unified-image-request-pipeline.md: 7af0bea0acb117844b258a0333e97e97facf493c +2026-08-20-unified-image-request-pipeline.zh.md: ecdb71548190ba22573a5e05c3b3fde76c28d511 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index ada15d5405..7af0bea0ac 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -22,15 +22,15 @@ Batch admission prepares and verifies every normalized attachment once before pu ### Deterministic request versions -`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a deterministic per-image placeholder that retains its identity and current provider access facts, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles -Every retained request image is preceded by its complete attachment id and actual request dimensions. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. +Every retained request image is preceded by its display name or complete attachment id, actual request dimensions, and current provider access facts. The local provider supplies the absolute read-only normalized-object path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is derived from the logged reference and current provider root at request time; it does not enter the durable reference or session log. ### DeepSeek Files lifecycle diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 85c9a1f837..ecdb715481 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -22,15 +22,15 @@ Status: implemented ### 确定性请求版本 -`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成确定性的逐图占位文本,保留自己的身份和当前提供方访问事实,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 ### 稳定句柄 -每张保留请求图片前都有完整附件 ID 和实际请求尺寸。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。 +每张保留请求图片前都有显示名称或完整附件 ID、实际请求尺寸,以及当前提供方访问事实。本地提供方会给出规范化对象的绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径在请求时根据已记录引用和当前提供方根目录派生,不进入持久引用或会话日志。 ### DeepSeek Files 生命周期 diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml new file mode 100644 index 0000000000..16831b0ca3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-21-model-readable-image-paths.md +2026-08-21-model-readable-image-paths.md: 4c2610ad48e47d2642e32ae53048d0897abd64e9 +2026-08-21-model-readable-image-paths.zh.md: 24335f1da99c382291988d5862bdd52e8d5d961c diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md new file mode 100644 index 0000000000..4c2610ad48 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md @@ -0,0 +1,37 @@ +# Agent Note: Provider-resolved image paths in model requests + +Status: implemented + +English | [中文](2026-08-21-model-readable-image-paths.zh.md) + +## Problem + +An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable models receive its request preview, but the prior descriptor gives them no filesystem location for later image operations. Agents consequently search the workspace and temporary directories or ask the user where the file is. The request preview and stored normalized attachment may both differ from the upload, so their dimensions, format, and byte size cannot establish the upload's original properties. + +## Decision + +`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute immutable-object path from the resolved `DSH_HOME`, attachment storage version, and validated digest. A request version carries these facts transiently for serialization. A provider without model-readable local storage returns no access facts. + +The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. + +Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and current access facts without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged. + +Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. + +Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the provider mounted for the current process. Restoring the same session with a different `DSH_HOME` therefore produces the path that is valid on that host. The attachment object remains immutable; model instructions require a writable copy for modifications. + +## Alternatives considered + +**Persist the absolute path in `ImageAttachmentRef`.** A durable host path becomes stale after moving a session, changing `DSH_HOME`, or mounting another provider. Resolving it at request time preserves portable history. + +**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact. + +**Add a dedicated crop or recovery tool.** Standard filesystem and image tools can operate after copying the normalized object. A new tool adds a model schema and access-policy surface without being necessary for path discovery. + +## Verification + +Package tests cover provider access defaults, local digest-to-path resolution, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. + +## Consequences + +The selected model provider receives a host path that was previously local-only. This disclosure is required for the model to operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. A missing local object still fails when a model tool attempts to read it. diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md new file mode 100644 index 0000000000..24335f1da9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md @@ -0,0 +1,37 @@ +# Agent Note:在模型请求中提供由附件提供方解析的图片路径 + +状态:已实现 + +[English](2026-08-21-model-readable-image-paths.md) | 中文 + +## 问题 + +上传图片会变成不透明的持久 `ImageAttachmentRef`。支持图片的模型可以收到请求预览,但原有描述没有给出供后续图片操作使用的文件系统位置。因此 agent 会搜索工作区和临时目录,或询问用户文件在哪里。请求预览和存储的规范化附件都可能与上传图片不同,所以它们的尺寸、格式和字节数不能证明上传图片原本的属性。 + +## 决策 + +`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生不可变对象的绝对路径。请求版本只在序列化期间携带这些事实。没有模型可读本地存储的提供方不返回访问事实。 + +共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 + +请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和当前访问事实,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。 + +描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 + +绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。因此,同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径。附件对象保持不可变;模型指令要求先复制到可写位置再修改。 + +## 考虑过的替代方案 + +**把绝对路径持久保存到 `ImageAttachmentRef`。** 移动会话、更改 `DSH_HOME` 或挂载其他提供方后,持久宿主路径会失效。请求时解析可以保持历史可移植。 + +**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。 + +**增加专用裁剪或恢复工具。** 把规范化对象复制出来后,标准文件系统与图片工具已经可以处理它。新增工具会增加模型 schema 和访问策略范围,但路径发现不需要它。 + +## 验证 + +包测试覆盖提供方访问默认值、本地摘要到路径的解析、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 + +## 后果 + +所选模型提供方会收到此前只在本地存在的宿主路径。模型需要该信息才能操作存储图片,并且路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。本地对象缺失时,模型工具读取它仍会失败。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aed3574106..ea79995c64 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: a845fe22e13ed085765668c7ec8d54d6bbdf129a -config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e +config-catalog.md: ffea1ee18359fd6f7cc9171d103e143b8bcd9e5b +config-catalog.zh.md: c447104fcb00d0df3625f6cae89ca128bfa44b57 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a845fe22e1..ffea1ee183 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) @@ -941,12 +941,10 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte cap for one deterministic request preview. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 39ba9d4836..c447104fcb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -319,7 +319,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) @@ -943,12 +943,10 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte cap for one deterministic request preview. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } ``` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index b93c9ef1ca..17c9d1bf47 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: e6d0a53db2827a38a1535380319b6220aa37f0a4 -attachment.zh.md: 8328ec610d4d68624f75f00d6a397b13fdf31c4e +attachment.md: fef6b3ad40f424a82049cddc761efa483fdda6f3 +attachment.zh.md: def50f79f33cec9b9262823d481a2d03429f0845 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index e6d0a53db2..fef6b3ad40 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen ## Identity and verified metadata -`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. Consumers call `imageAccess()` when they need a path resolved by the mounted provider. ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,6 +93,14 @@ interface StoredImageAttachment { } ``` +```ts type-equiv +/** Provider-resolved ways for model tools to access one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -110,6 +118,8 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef + /** Transient provider access facts; never persisted in the durable reference. */ + access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -125,7 +135,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageAccess()` resolves current-provider access facts without storing host paths in session data. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -176,6 +186,13 @@ abstract saveImage(input: SaveImageAttachment): Promise */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +/** + * Resolve provider-specific model-tool access without adding host facts to session history. + * @param ref - durable normalized attachment reference. + * @returns current-provider access facts, or undefined when this backend exposes no local path. + */ +imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 8328ec610d..def50f79f3 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -10,7 +10,7 @@ ## 标识与经过校验的元数据 -`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。 +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方需要路径时调用 `imageAccess()`,由当前挂载的提供方负责解析。 ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,6 +93,14 @@ interface StoredImageAttachment { } ``` +```ts type-equiv +/** Provider-resolved ways for model tools to access one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -110,6 +118,8 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef + /** Transient provider access facts; never persisted in the durable reference. */ + access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -125,7 +135,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageAccess()` 解析当前提供方的访问信息,无需把宿主路径写入会话数据。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -176,6 +186,13 @@ abstract saveImage(input: SaveImageAttachment): Promise */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise +/** + * Resolve provider-specific model-tool access without adding host facts to session history. + * @param ref - durable normalized attachment reference. + * @returns current-provider access facts, or undefined when this backend exposes no local path. + */ +imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e0946004aa..3e8f3aa982 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -16,7 +16,6 @@ import { } from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' /** * The acp-agent example's snapshot suite: the scenario table for @@ -807,16 +806,36 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(result.stderr).toBe('') expect(requests).toHaveLength(2) expect(fileRequests).toEqual([{ method: 'POST', path: '/files', bytes: 69 }]) + const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' + const attachmentId = `sha256:${attachmentDigest}` + const accessText = (cwd: string): string => { + const attachmentPath = join( + cwd, + '.dsh', + 'attachments', + 'v1', + 'objects', + attachmentDigest.slice(0, 2), + attachmentDigest, + ) + return ` Normalized copy (read-only; may be resized or re-encoded): ${JSON.stringify(attachmentPath)} (1x1px, image/png).` + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .png before editing.' + } + const normalizedAccess = accessText(result.cwd) + const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` + const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` + const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` + .replaceAll(result.cwd, '{{cwd}}') const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toEqual([ { type: 'text', text: 'Compare the older image ' }, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImage }, { type: 'text', text: ' with the newer image ' }, { type: 'text', - text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' - + 'request image 1x1px.', + text: `\n${imageHandle}`, }, { type: 'file', file_id: 'file-api-snapshot-1' }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, @@ -839,7 +858,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(followup).toEqual([ { role: 'user', - content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`, + content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`, }, { role: 'user', @@ -860,7 +879,7 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests role: 'tool', tool_call_id: 'native-read-image', content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; request image 1x1px.', + + `\n${normalizedToolImageHandle}`, }, { role: 'user', @@ -891,15 +910,12 @@ it('pins native DeepSeek Files offload and inline fallback in assembled requests expect(requests).toHaveLength(3) const fallbackMessages = requests[2]?.messages as { content?: unknown }[] | undefined const fallbackInput = fallbackMessages?.find(message => JSON.stringify(message.content).includes('[image omitted')) + const fallbackAccess = accessText(fallback.cwd) expect(fallbackInput?.content).toEqual([ { type: 'text', text: 'Compare the older image ' }, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` }, { type: 'text', text: ' with the newer image ' }, - { - type: 'text', - text: '\nImage sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640; ' - + 'request image 1x1px.', - }, + { type: 'text', text: `\nImage ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, ]) diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 3698abdcb2..a82a48a906 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: 3ed4ab3251b0a609807c76930226bec63f0164cd -README.zh.md: 85abd10389acc46c2d89dd85628f5d201b089710 +README.md: e23fe75a32a873f3f3762c14060c92c66be3a12a +README.zh.md: 646d1d8f26df382be7b0c2c18f2b6ed72661f3a4 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 3ed4ab3251..e23fe75a32 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -8,11 +8,11 @@ Admission accepts at most 20 images and 200MiB of encoded source bytes per messa Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the absolute normalized-object path from that reference and the current provider root. The path is host-specific, read-only, and absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience -Indirectly, through durable replay of historical user images and structured model image output after restart and fork. +Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, current read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. #### KV Cache effect diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 85abd10389..646d1d8f26 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -8,11 +8,11 @@ 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 根据该引用和当前提供方根目录派生规范化对象的绝对路径。该路径属于当前宿主,只供读取,不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 -该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。 +该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 #### KV 缓存影响 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index e9a1145ba5..31d9f38014 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { + ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -15,7 +16,7 @@ import type { import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' -import { commitPreparedImageFile, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' export { canPassThroughNormalization, normalizeImage } from './normalization.ts' @@ -207,6 +208,10 @@ export class LocalAttachmentStore extends AttachmentStore { return readImageFile(this.root, ref, signal) } + override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess { + return { readonlyPath: normalizedImagePath(this.root, ref) } + } + override async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, @@ -230,12 +235,15 @@ export class LocalAttachmentStore extends AttachmentStore { operation = undefined } if (operation === undefined) { - const shared = new SharedRequest(sharedSignal => this.compression.run(async () => readRequestImageFile( - this.root, - stored ?? await this.readImage(ref, sharedSignal), - policy, - sharedSignal, - ))) + const shared = new SharedRequest(sharedSignal => this.compression.run(async () => ({ + ...await readRequestImageFile( + this.root, + stored ?? await this.readImage(ref, sharedSignal), + policy, + sharedSignal, + ), + access: this.imageAccess(ref), + }))) operation = shared this.requestInflight.set(key, shared) void shared.promise.finally(() => { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 5fbb8e9201..266d4f8e34 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -36,16 +36,23 @@ function displayName(value: string | undefined): string | undefined { return clean === '' ? undefined : clean } -function objectPath(root: string, sha256: string): string { - return join(root, 'objects', sha256.slice(0, 2), sha256) -} - function ensureReference(ref: ImageAttachmentRef): string { const match = ID_PATTERN.exec(String(ref.attachmentId)) if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') return match[1] } +/** + * Derive the absolute immutable-object path for one normalized attachment. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable normalized attachment reference. + * @returns provider-local path without reading the object. + */ +export function normalizedImagePath(root: string, ref: ImageAttachmentRef): string { + const sha256 = ensureReference(ref) + return join(root, 'objects', sha256.slice(0, 2), sha256) +} + async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], @@ -199,7 +206,7 @@ export async function commitPreparedImageFile( await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) - const target = objectPath(root, sha256) + const target = normalizedImagePath(root, prepared.ref) let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) @@ -275,7 +282,7 @@ export async function readImageFile( const sha256 = ensureReference(ref) let data: Uint8Array try { - data = new Uint8Array(await readFile(objectPath(root, sha256), { signal })) + data = new Uint8Array(await readFile(normalizedImagePath(root, ref), { signal })) } catch (error) { signal?.throwIfAborted() if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index f8deea3c5c..e5c409b1d6 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -37,6 +38,21 @@ describe('local attachment service', () => { maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY) + const ref = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } + expect(service.imageAccess(ref).readonlyPath).toBe(join( + service.root, + 'objects', + 'aa', + 'a'.repeat(64), + )) + expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) }) it('resolves and validates the instance image-compression concurrency', () => { @@ -57,6 +73,18 @@ describe('local attachment service', () => { )) const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) + const access = service.imageAccess(ref) + expect(access.readonlyPath).toBe(join( + dshHome, + 'attachments', + 'v1', + 'objects', + String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 2), + String(ref.attachmentId).slice('sha256:'.length), + )) + await expect(readFile(access.readonlyPath)).resolves.toEqual(Buffer.from(data)) + const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) + expect(request.access).toEqual(access) } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index e27f25e933..1a55f27a2c 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 3ad568c7308f1ab85cb4af3fcc2afd3cba9a611a -README.zh.md: fadbb1c5bbf097c599da651055d63a1ed64cd579 +README.md: 63c6a41fca3d0d7ea87d9f30e682e7e4e7aa5906 +README.zh.md: 4eb08dd169e1d0f2b20ada050513caaf88a84891 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 3ad568c730..63c6a41fca 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,15 +2,15 @@ English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageAccess` resolves optional current-provider access facts, such as an absolute read-only path, without adding host-specific data to the durable reference. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. A backend that offers model-tool access can also expose its current read-only normalized path; the descriptor states that normalization may have resized or re-encoded the upload. #### KV Cache effect diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index fadbb1c5bb..4eb08dd169 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageAccess` 解析当前提供方可选的访问事实,例如绝对只读路径,同时避免把宿主信息加入持久引用。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。 +该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。支持模型工具访问的后端还可以公开当前规范化附件的只读路径;描述会说明规范化过程可能缩小或重新编码上传图片。 #### KV 缓存影响 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 8b54926efa..e10ce2d214 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -4,6 +4,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentError } from './error.ts' import type { ImageAttachmentLimits, + ImageAttachmentAccess, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, @@ -18,6 +19,7 @@ export { admitEncodedImages } from './admission.ts' export type { AttachmentId as AttachmentIdType, EncodedImageAttachment, + ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -107,6 +109,16 @@ export abstract class AttachmentStore extends Service { */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + /** + * Resolve provider-specific model-tool access without adding host facts to session history. + * @param ref - durable normalized attachment reference. + * @returns current-provider access facts, or undefined when this backend exposes no local path. + */ + imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { + void ref + return undefined + } + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index e23a7a7d4c..0b7d382b64 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -67,6 +67,12 @@ export interface StoredImageAttachment { data: Uint8Array } +/** Provider-resolved ways for model tools to access one normalized attachment. */ +export interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} + /** Deterministic request-image policy selected by one exact model route. */ export interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ @@ -81,6 +87,8 @@ export interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef + /** Transient provider access facts; never persisted in the durable reference. */ + access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index be784f0276..a28bcd6c1c 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -146,6 +146,12 @@ describe('AttachmentStore.readImageRequest', () => { controller.abort(reason) expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) + + it('exposes no provider-specific path by default', async () => { + const store = new RecordingStore(new Context()) + const ref = await store.saveImage(image(1)) + expect(store.imageAccess(ref)).toBeUndefined() + }) }) describe('isImageAdmissionError', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6f7d362aa8..197ff6fadd 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -449,6 +449,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the verified bytes and normalized attachment reference.', throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, + { + signature: 'imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined', + description: 'Resolve provider-specific model-tool access without adding host facts to session history.', + parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }], + returns: 'current-provider access facts, or undefined when this backend exposes no local path.', + }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', @@ -3713,6 +3719,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GrantRecord', declaration: 'export interface GrantRecord {\n readonly kind: \'grant\';\n readonly payload: unknown;\n}', }, + { + name: 'ImageAttachmentAccess', + declaration: 'export interface ImageAttachmentAccess {\n readonlyPath: string;\n}', + }, { name: 'ImageAttachmentLimits', declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n maxImageDimension: number;\n mediaTypes: readonly ImageMediaType[];\n}', @@ -4219,7 +4229,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestImageAttachment', - declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n access?: ImageAttachmentAccess;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', }, { name: 'RequestRunOutcome', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 8076a692e6..dc0fe9ccb6 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 7d50a8e99863637a06abf49d26a6eeb419cf1bbd -README.zh.md: c1571149529a2d4e54d67b10f63b60bb2aa1abfd +README.md: 31aa82dcc0dde68b7ed23edcd0dc477320d55505 +README.zh.md: dad1dbcea639130ff55430684d6478a86717154c diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7d50a8e998..31aa82dcc0 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,9 +52,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id, actual request dimensions, and the local normalized-object path when its attachment provider exposes one. The text marks that path read-only, gives the matching extension for a writable copy, and states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. -`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. +`maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Each removed image becomes its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only local path. This high-watermark projection avoids changing an old request prefix after every new image. Inline fallback has an independent base64 budget. `maxInlineRequestImageBytes` defaults to 20MiB and `inlineImageOffloadByteQuantum` to 10MiB, so a history of 21 one-megabyte base64 payloads removes the oldest 11 and retains 10MiB. The calculation uses base64-expanded lengths. The prepared request versions are reused byte-for-byte; fallback does not decode or compress an image again. Successful mappings created before a later image fails remain indexed for future requests. @@ -115,7 +115,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles and request-image dimensions; a Files resolution failure sends all retained images as inline data URLs instead. An over-budget older image is represented by the documented placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles, request-preview dimensions, and the current normalized-object path when available; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the same access facts in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c157114952..dad1dbcea6 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,9 +52,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及附件提供方支持时的本地规范化对象路径。文本会把该路径标记为只读,指出复制到可写路径时应使用的匹配扩展名,并说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 -`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 +`maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。每张被移除的图片都有自己的模型可见占位文本,其中包含显示名称或附件 ID;如果当前提供方支持,还会包含规范化尺寸、媒体类型和当前只读本地路径。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 内联回退使用独立的 base64 预算。`maxInlineRequestImageBytes` 默认为 20MiB,`inlineImageOffloadByteQuantum` 默认为 10MiB,因此由 21 个 1MiB base64 负载组成的历史会移除最旧的 11 个并保留 10MiB。计算使用 base64 膨胀后的长度。系统逐字节复用已经准备好的请求版本;回退不会再次解码或压缩图片。前面图片已经成功写入的上传映射会保留,供后续请求复用。 @@ -115,7 +115,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄和请求图片尺寸;Files 解析失败时,所有保留图片改用内联 data URL。超出上限的较旧图片由已记录的占位文本表示。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄、请求预览尺寸,以及当前可用的规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留相同的访问事实。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index fe461fdb35..8b4140a7d1 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,7 +8,7 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, @@ -58,12 +58,10 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview. */ - imagePixelBudget?: number + /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + imagePixelBudget?: number | 'low' /** Encoded-byte cap for one deterministic request preview. */ imageMaxBytes?: number - /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ - imageDetail?: 'auto' | 'low' } /** @@ -206,10 +204,9 @@ function collectImageRefs( * @internal */ export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { - let maxPixels: number - if (model.imagePixelBudget !== undefined) maxPixels = model.imagePixelBudget - else if (model.imageDetail === 'low') maxPixels = DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - else maxPixels = DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET + const maxPixels = model.imagePixelBudget === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET return { maxPixels, maxBytes: model.imageMaxBytes === undefined @@ -548,6 +545,7 @@ export class DeepSeekAdapter extends LlmAdapter { byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, byteLength: ref => Math.min(ref.bytes, policy.maxBytes), + placeholder: ref => offloadedImageText(ref, attachments?.imageAccess(ref)), }) const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } const requestImages = attachments === undefined || model === undefined diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index e6faad74ce..747e37ca4c 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -151,9 +151,8 @@ const catalogModel: z = z.object({ contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), - imagePixelBudget: z.number().step(1).min(1), + imagePixelBudget: z.union([z.number().step(1).min(1), 'low']), imageMaxBytes: z.number().step(1).min(1), - imageDetail: z.union(['auto', 'low']), }) export const Config: z = z.object({ @@ -225,13 +224,13 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`) } const hasImage = inputModalities.includes('image') - if (!hasImage && (model.imagePixelBudget !== undefined - || model.imageMaxBytes !== undefined || model.imageDetail !== undefined)) { + if (!hasImage && (model.imagePixelBudget !== undefined || model.imageMaxBytes !== undefined)) { throw new Error(`llm-deepseek: text-only catalog model "${model.id}" cannot declare image request limits`) } if (model.imagePixelBudget !== undefined + && model.imagePixelBudget !== 'low' && (!Number.isSafeInteger(model.imagePixelBudget) || model.imagePixelBudget <= 0)) { - throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be a positive safe integer`) + throw new Error(`llm-deepseek: catalog model "${model.id}" imagePixelBudget must be "low" or a positive safe integer`) } if (model.imageMaxBytes !== undefined && (!Number.isSafeInteger(model.imageMaxBytes) || model.imageMaxBytes <= 0)) { @@ -248,12 +247,10 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee inputModalities: [...inputModalities], ...hasImage ? { - imagePixelBudget: model.imagePixelBudget - ?? (model.imageDetail === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET), + imagePixelBudget: model.imagePixelBudget === 'low' + ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET + : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, - ...model.imageDetail === undefined ? {} : { imageDetail: model.imageDetail }, } : {}, } diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 3b22967d96..e1218ba16c 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,7 +6,7 @@ * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { @@ -125,12 +125,13 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { /** Describe the exact request preview and its model-callable coordinate system. */ function imageHandle( + ref: ImageAttachmentRef, version: RequestImageAttachment, precededByContent: boolean, ): WireTextContentPart { return { type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImageHandleText(version)}`, + text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version)}`, } } @@ -154,7 +155,7 @@ async function imageParts( type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, } - return [imageHandle(version, precededByContent), image] + return [imageHandle(block.attachment, version, precededByContent), image] } /** Convert user or nested tool-result blocks into ordered wire parts. */ @@ -415,6 +416,10 @@ export async function serializeRequestWithImages( ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, + placeholder: (ref) => { + const version = images.requestImages.get(ref.attachmentId) + return offloadedImageText(ref, version?.access) + }, }) const messages: WireMessage[] = [] if (options.system !== undefined) { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 5a32a35664..9408f1595b 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -109,7 +109,7 @@ function attachmentStoreOf( } { const readImageRequest = vi.fn(project) return { - store: { readImageRequest } as unknown as AttachmentStore, + store: { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore, readImageRequest, } } @@ -147,7 +147,7 @@ describe('request image policy', () => { { maxPixels: 640_000, maxBytes: 1024 * 1024 }, ], [ - { id: 'low', imageDetail: 'low' as const }, + { id: 'low', imagePixelBudget: 'low' as const }, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }, ], [ @@ -367,7 +367,7 @@ describe('DeepSeekAdapter against a mock server', () => { role: 'user', content: [ { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request image 1x1px.`) as string }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, { type: 'file', file_id: 'file-api-1' }, ], }], @@ -434,7 +434,7 @@ describe('DeepSeekAdapter against a mock server', () => { })) const body = JSON.stringify(server.requests[0]) - expect(body.match(/older images are omitted first/g)).toHaveLength(11) + expect(body.match(/image omitted to fit request image limits/g)).toHaveLength(11) expect(body.match(/"type":"image_url"/g)).toHaveLength(10) }) @@ -600,7 +600,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(body.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, { type: 'file', file_id: 'file-api-1' }, ], @@ -619,7 +619,7 @@ describe('DeepSeekAdapter against a mock server', () => { { id: 'vision-low', inputModalities: ['text', 'image'], - imageDetail: 'low', + imagePixelBudget: 'low', imageMaxBytes: 512_000, }, { @@ -1907,8 +1907,9 @@ describe('plugin registration and config', () => { }) it.each([ - ['imagePixelBudget', 0, /imagePixelBudget must be a positive safe integer/], - ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be a positive safe integer/], + ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], ] as const)('rejects per-model %s=%s', (field, value, message) => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 4617ebdfed..fd6759c152 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -208,7 +208,7 @@ describe('request-level dynamic configuration', () => { const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) - expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit') + expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 968ceabdaf..cb94b3c237 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -367,7 +367,7 @@ describe('image serialization', () => { role: 'user', content: [ { type: 'text', text: 'before' }, - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request image 1x1px`) as string }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, { type: 'file', file_id: 'file-api-image' }, { type: 'text', text: 'after' }, ], @@ -392,7 +392,7 @@ describe('image serialization', () => { expect(wire.messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, + { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, { type: 'image_url', image_url: { url } }, ], }]) @@ -411,12 +411,41 @@ describe('image serialization', () => { expect(wire.messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: `Image ${ref.attachmentId}; request image 1x1px.` }, + { + type: 'text', + text: `Image ${ref.attachmentId}; request preview 1x1px. It may be resized or re-encoded; source dimensions, format, and byte size may differ.`, + }, { type: 'file', file_id: 'file-api-image' }, ], }]) }) + it('includes provider-resolved normalized access in a retained image handle', async () => { + const ref = { ...imageRef(), name: 'diagram.png', width: 2048, height: 1024 } + const images = imageOptions([ref]) + const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment + version.width = 1130 + version.height = 565 + version.access = { readonlyPath: '/tmp/dsh/objects/aa/object' } + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), images) + + expect(wire.messages[0]).toMatchObject({ + role: 'user', + content: [{ + type: 'text', + text: expect.stringContaining('Image "diagram.png"') as string, + }, { type: 'file' }], + }) + expect(JSON.stringify(wire.messages[0])).toContain('/tmp/dsh/objects/aa/object') + expect(JSON.stringify(wire.messages[0])).toContain('request preview 1130x565px') + }) + it('rejects an image whose prepared request version is absent', async () => { const ref = imageRef() await expect(serializeMessagesWithImages([createUserMessage({ @@ -546,14 +575,14 @@ describe('image serialization', () => { { role: 'tool', tool_call_id: 'before-system', - content: expect.stringContaining('request image 1x1px') as string, + content: expect.stringContaining('request preview 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'system', content: 'system history' }, { role: 'tool', tool_call_id: 'before-assistant', - content: expect.stringContaining('request image 1x1px') as string, + content: expect.stringContaining('request preview 1x1px') as string, }, expect.objectContaining({ role: 'user' }), { role: 'assistant', content: 'assistant history' }, @@ -564,6 +593,9 @@ describe('image serialization', () => { const resolveFileId = fileResolver() const png = imageRef('image/png', 3) const jpeg = imageRef('image/jpeg', 3) + const images = imageOptions([png, jpeg], resolveFileId, 4) + const pngVersion = images.requestImages.get(png.attachmentId) as RequestImageAttachment + pngVersion.access = { readonlyPath: '/tmp/dsh/objects/png' } const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ @@ -573,12 +605,15 @@ describe('image serialization', () => { ], source: { kind: 'plugin', plugin: 'test' }, })], - }), imageOptions([png, jpeg], resolveFileId, 4)) + }), images) expect(wire.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { + type: 'text', + text: expect.stringContaining(`image omitted to fit request image limits; ${png.attachmentId}. Normalized copy (read-only; may be resized or re-encoded): "/tmp/dsh/objects/png"`) as string, + }, { type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string }, { type: 'file', file_id: 'file-api-image' }, ], @@ -598,7 +633,7 @@ describe('image serialization', () => { }), inlineImageOptions([ref], 80, 40)) const content = wire.messages[0]?.content - expect(JSON.stringify(content).match(/older images are omitted first/g)).toHaveLength(11) + expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11) expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index a7021eb1cd..67377a2daa 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 9ef6596490b614a3d4af3dd6b52eb9b9fe87a335 -README.zh.md: 10f366659a38f52f7700c0db7953b983fd0e623a +README.md: 7f0c2a32315200e3244cb83d396bbf2ee30d728f +README.zh.md: f11d4ba1d991cc70c1967eef8f8b880727df36ce diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 9ef6596490..7f0c2a3231 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with fixed text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id and actual request-image dimensions. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id, actual request-image dimensions, and provider-resolved normalized-object path when available. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id and actual request dimensions. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text that tells the model to read the file again when a path is available or ask the user to attach it again. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id, actual request dimensions, and current normalized-object path when available. The descriptor marks that path read-only and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and available local path in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 10f366659a..f11d4ba1d9 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界替换超预算的最旧图片;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID 和实际请求图片尺寸。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID、实际请求图片尺寸,以及当前提供方支持时的规范化对象路径。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片会从最老开始替换为固定文本,要求模型在有路径时重新读取文件,否则请用户重新附上图片。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及当前可用的规范化对象路径。描述会把该路径标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和可用本地路径。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 9faf457c9a..db99f86d8e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentId, @@ -57,7 +57,7 @@ async function userContent( break case 'image': { const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment - content.push({ type: 'text', text: requestImageHandleText(version) }) + content.push({ type: 'text', text: requestImageHandleText(block.attachment, version) }) content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), @@ -231,6 +231,7 @@ async function toPiContextWithImages( ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes), + placeholder: ref => offloadedImageText(ref, attachments.imageAccess(ref)), }) const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal) const exactMessages = offloadRequestImagesWithPolicy(requestMessages, { @@ -238,6 +239,7 @@ async function toPiContextWithImages( ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, + placeholder: ref => offloadedImageText(ref, requestImages.get(ref.attachmentId)?.access), }) const toolNames = new Map() const messages: PiMessage[] = [] diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 026ca2c608..a971b80c85 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -6,7 +6,7 @@ import type { ImageRequestPolicy, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' +import { CallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { toPiContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' @@ -43,7 +43,7 @@ function projectionStore( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { readImageRequest } as unknown as AttachmentStore + return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore } const attachments = projectionStore() @@ -174,6 +174,26 @@ describe('pi-ai request context conversion', () => { ]) }) + it('uses the shared normalized-path description for retained images', async () => { + const named = { ...ref, name: 'chart.png', width: 2048, height: 1024 } + const store = projectionStore(value => Promise.resolve({ + ...requestImage(value, Uint8Array.of(1)), + width: 1130, + height: 565, + access: { readonlyPath: '/tmp/dsh/objects/aa/object' }, + })) + const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), store) + expect(context.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining('Image "chart.png"') as string }, + { type: 'image' }, + ], + }) + expect(JSON.stringify(context.messages[0])).toContain('/tmp/dsh/objects/aa/object') + expect(JSON.stringify(context.messages[0])).toContain('request preview 1130x565px') + }) + it('recursively converts nested tool-result text and images', async () => { const callId = CallId('nested-call') const context = await toPiContext(request([user([{ @@ -252,7 +272,7 @@ describe('pi-ai request context conversion', () => { role: 'toolResult', toolCallId: 'shot-call', toolName: 'unknown', - content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + content: [{ type: 'text', text: offloadedImageText(sized) }], isError: false, timestamp: 0, }, @@ -293,7 +313,7 @@ describe('pi-ai request context conversion', () => { expect(context.messages[0]).toMatchObject({ role: 'user', content: [ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImageText(old) }, { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, { type: 'image' }, ], @@ -302,6 +322,26 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) }) + it('uses the generated request access when exact encoded bytes require offload', async () => { + const sized: ImageAttachmentRef = { ...ref, bytes: 3 } + const access = { readonlyPath: '/tmp/dsh-normalized-image' } + const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve({ + ...requestImage(value, Uint8Array.of(1, 2, 3, 4)), + access, + })) + + const context = await toPiContext(request([ + user([{ type: 'image', attachment: sized }]), + ]), projectionStore(readImageRequest), undefined, 4) + + expect(context.messages).toEqual([{ + role: 'user', + content: offloadedImageText(sized, access), + timestamp: 0, + }]) + expect(readImageRequest).toHaveBeenCalledTimes(1) + }) + it('keeps every image at exactly the payload bound and drops all of them when even the newest cannot fit', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const exact = await toPiContext(request([ @@ -330,7 +370,7 @@ describe('pi-ai request context conversion', () => { ]), store, undefined, 8) // All-text content collapses to the string form; the placeholder still reaches the model. expect(oversized.messages).toEqual([ - { role: 'user', content: OFFLOADED_IMAGE_TEXT, timestamp: 0 }, + { role: 'user', content: offloadedImageText({ ...ref, bytes: 300 }), timestamp: 0 }, ]) expect(readImageRequest).not.toHaveBeenCalled() }) @@ -351,7 +391,7 @@ describe('pi-ai request context conversion', () => { const expected = [{ role: 'user', content: [ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: offloadedImageText(sized) }, { type: 'text', text: expect.stringContaining(`Image ${sized.attachmentId}`) as string }, { type: 'image', data: 'AQID', mimeType: 'image/png' }, ], diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index ed4df11a4d..1d53dbdab0 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -63,7 +63,7 @@ function attachmentStore(readImageRequest: ( policy: ImageRequestPolicy, signal?: AbortSignal, ) => Promise): AttachmentStore { - return { readImageRequest } as unknown as AttachmentStore + return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore } describe('toPiContext', () => { diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 08820bbfd9..3ac89ec262 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: 59c5303bdae8d6391f3bb1595a527d677e2378bc -README.zh.md: 313fc23590de2119bf07dd5c4c4fafe9daa94c56 +README.md: d08d28497c4821c578fac43515b3bebfa4ed8131 +README.zh.md: 3d7a183aa2319acd869d8e4bc68ec9493c3ea322 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 59c5303bda..d08d28497c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -57,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. -Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length. +Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content. @@ -91,11 +91,11 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l ## Model Experience -None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort. +None, as adapters choose when to add the shared image descriptors and per-image placeholders exported by this package, while the LLM service itself only materializes and logs adapter-configured request facts. #### KV Cache effect -Pass-through; the registry preserves the assembled request prefix, while the selected adapter and provider own cache reuse and routing boundaries. +Reasoning-effort materialization preserves the assembled request prefix. Image descriptors add deterministic text beside each image, and a request-limit transition replaces the deterministic oldest prefix with per-image text. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 313fc23590..3d7a183aa2 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -57,7 +57,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。 -每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度。 +每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。 流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。 @@ -93,11 +93,11 @@ ## 模型体验 -无。服务不添加任何与模型绑定的文本、schema 或消息;它只会填入并记录适配器配置的推理强度。 +无。适配器决定何时加入该包导出的共用图片描述和逐图占位文本,LLM 服务本身只会填入并记录适配器配置的请求事实。 #### KV Cache 影响 -透传;注册表保留已组装请求前缀,cache 复用与路由边界属于所选适配器和提供方。 +推理强度填入不会改变已组装的请求前缀。图片描述会在每张图片旁加入确定性文本;请求越过上限时,确定性的最旧前缀会替换为逐图文本。 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 4620275429..10076ad529 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,15 +2,38 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentAccess, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { assertNever } from './never.ts' -/** Model-facing stand-in for an image removed to fit a provider request bound. */ -export const OFFLOADED_IMAGE_TEXT - = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' +function quoted(value: string): string { + return JSON.stringify(value) +} + +function imageIdentity(ref: ImageAttachmentRef): string { + return ref.name === undefined + ? String(ref.attachmentId) + : `${quoted(ref.name)} (${ref.attachmentId})` +} + +function extension(mediaType: ImageMediaType): string { + switch (mediaType) { + case 'image/png': return '.png' + case 'image/jpeg': return '.jpg' + case 'image/webp': return '.webp' + case 'image/gif': return '.gif' + default: return assertNever(mediaType, 'image extension') + } +} + +function normalizedAccessText(ref: ImageAttachmentRef, access: ImageAttachmentAccess): string { + return ` Normalized copy (read-only; may be resized or re-encoded): ${quoted(access.readonlyPath)} (${ref.width}x${ref.height}px, ${ref.mediaType}).` + + ' Source dimensions, format, and byte size may differ.' + + ` Copy to a writable path ending in ${extension(ref.mediaType)} before editing.` +} /** * Stable text shown to a model that cannot accept one durable image reference. - * @param ref - durable master reference omitted from the request. + * @param ref - durable normalized attachment omitted from the request. * @returns deterministic text-only placeholder. */ export function textOnlyImageText(ref: ImageAttachmentRef): string { @@ -19,12 +42,36 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { } /** - * Stable model-facing handle for one exact request image. + * Stable model-facing handle for one exact request image. Identity comes from + * the occurrence's own durable reference: request versions are prepared per + * attachment id, so one shared version may serve occurrences whose display + * names differ. + * @param ref - the occurrence's durable normalized attachment. * @param version - exact request image shown beside the text. * @returns attachment handle and request-image dimensions. */ -export function requestImageHandleText(version: RequestImageAttachment): string { - return `Image ${version.attachment.attachmentId}; request image ${version.width}x${version.height}px.` +export function requestImageHandleText(ref: ImageAttachmentRef, version: RequestImageAttachment): string { + const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.` + return version.access === undefined + ? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.` + : preview + normalizedAccessText(ref, version.access) +} + +/** + * Stable per-image placeholder for a request-limit omission. + * @param ref - durable normalized attachment omitted from this request. + * @param access - optional provider-resolved path for model tools. + * @returns identity, normalized metadata, and the available recovery path. + */ +export function offloadedImageText( + ref: ImageAttachmentRef, + access?: ImageAttachmentAccess, +): string { + const identity = `image omitted to fit request image limits; ${imageIdentity(ref)}.` + if (access === undefined) { + return `[${identity} No local normalized image path is available; ask the user to attach it again if needed.]` + } + return `[${identity}${normalizedAccessText(ref, access)}]` } /** @@ -57,8 +104,10 @@ export interface RequestImageOffloadPolicy { byteQuantum?: number /** Whether byte accounting uses raw file bytes or inline base64 length. */ representation: 'raw' | 'base64' - /** Resolve the encoded request-version length; omission uses master attachment bytes. */ + /** Resolve the encoded request-version length; omission uses normalized attachment bytes. */ byteLength?: (ref: ImageAttachmentRef) => number + /** Build the model-visible replacement for each omitted attachment. */ + placeholder: (ref: ImageAttachmentRef) => string } /** Collect represented image lengths in request and nested-block order. */ @@ -83,17 +132,18 @@ function collectImageLengths( function replaceOldestImages( blocks: readonly ContentBlock[], remaining: { count: number }, + placeholder: (ref: ImageAttachmentRef) => string, ): ContentBlock[] { let next: ContentBlock[] | undefined for (const [index, block] of blocks.entries()) { if (block.type === 'image' && remaining.count > 0) { remaining.count -= 1 next ??= blocks.slice(0, index) - next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) + next.push({ type: 'text', text: placeholder(block.attachment) }) continue } if (block.type === 'tool-result') { - const content = replaceOldestImages(block.content, remaining) + const content = replaceOldestImages(block.content, remaining, placeholder) if (content !== block.content) { next ??= blocks.slice(0, index) next.push({ ...block, content }) @@ -140,26 +190,6 @@ export function projectImagesForTextModel(messages: readonly Message[]): readonl }) } -/** - * Return transient request messages whose oldest images are replaced until - * their accumulated base64 payload fits the configured bound. The selection - * is deterministic from durable message order and attachment metadata; a - * provider can serialize the returned messages without reading omitted bytes. - * @param messages - complete request history, oldest first. - * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image. - * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees. - */ -export function offloadRequestImages( - messages: readonly Message[], - maxRequestImageBytes: number | undefined, -): readonly Message[] { - return offloadRequestImagesWithPolicy(messages, { - representation: 'base64', - ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, - byteQuantum: 1, - }) -} - /** * Return a deterministic transient projection whose oldest images are replaced * in whole count and byte quanta after a route budget is exceeded. The target @@ -196,7 +226,7 @@ export function offloadRequestImagesWithPolicy( } const remaining = { count } return messages.map((message) => { - const content = replaceOldestImages(message.content, remaining) + const content = replaceOldestImages(message.content, remaining, policy.placeholder) return content === message.content ? message : { ...message, content } }) } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 6a0eb02c63..236cee2297 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -1,18 +1,30 @@ import { describe, expect, it } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import { CallId, createUserMessage, - OFFLOADED_IMAGE_TEXT, - offloadRequestImages, + offloadedImageText, offloadRequestImagesWithPolicy, projectImagesForTextModel, + requestImageHandleText, } from '../src/index.ts' -import type { ContentBlock } from '../src/index.ts' +import type { ContentBlock, Message } from '../src/index.ts' const source = { kind: 'plugin' as const, plugin: 'test' } -function image(bytes: number): ContentBlock { +const OMITTED = '[omitted]' + +function offloadBase64(messages: readonly Message[], maxBytes: number | undefined): readonly Message[] { + return offloadRequestImagesWithPolicy(messages, { + representation: 'base64', + ...maxBytes === undefined ? {} : { maxBytes }, + byteQuantum: 1, + placeholder: () => OMITTED, + }) +} + +function image(bytes: number): Extract { return { type: 'image', attachment: { @@ -25,15 +37,15 @@ function image(bytes: number): ContentBlock { } } -describe('offloadRequestImages', () => { +describe('base64 request-image offload', () => { it('preserves every image when no payload bound is configured', () => { const messages = [createUserMessage({ content: [image(300)], source })] - expect(offloadRequestImages(messages, undefined)).toBe(messages) + expect(offloadBase64(messages, undefined)).toBe(messages) }) it('preserves the original request when its base64 payload fits exactly', () => { const messages = [createUserMessage({ content: [image(3), image(3)], source })] - expect(offloadRequestImages(messages, 8)).toBe(messages) + expect(offloadBase64(messages, 8)).toBe(messages) }) it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => { @@ -43,14 +55,14 @@ describe('offloadRequestImages', () => { content: Array.from({ length: 5 }, () => image(rawImageBytes)), source, })] - expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact) + expect(offloadBase64(exact, maxRequestImageBytes)).toBe(exact) const over = [createUserMessage({ content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))], source, })] - expect(offloadRequestImages(over, maxRequestImageBytes)[0]?.content).toEqual([ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + expect(offloadBase64(over, maxRequestImageBytes)[0]?.content).toEqual([ + { type: 'text', text: OMITTED }, ...Array.from({ length: 4 }, () => image(rawImageBytes)), ]) }) @@ -69,12 +81,12 @@ describe('offloadRequestImages', () => { createUserMessage({ content: [shared, image(3)], source }), ] - const fitted = offloadRequestImages(messages, 8) + const fitted = offloadBase64(messages, 8) expect(fitted).not.toBe(messages) expect(fitted[0]?.content).toEqual([{ type: 'tool-result', toolCallId: CallId('shot'), - content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + content: [{ type: 'text', text: OMITTED }], }]) expect(fitted[1]?.content).toEqual([shared, image(3)]) expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] }) @@ -82,8 +94,8 @@ describe('offloadRequestImages', () => { it('replaces a single image that cannot fit', () => { const messages = [createUserMessage({ content: [image(300)], source })] - expect(offloadRequestImages(messages, 8)[0]?.content) - .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }]) + expect(offloadBase64(messages, 8)[0]?.content) + .toEqual([{ type: 'text', text: OMITTED }]) }) it('keeps unchanged nested content while replacing a later image', () => { @@ -93,9 +105,9 @@ describe('offloadRequestImages', () => { content: [{ type: 'text' as const, text: 'kept' }], } const messages = [createUserMessage({ content: [nested, image(3)], source })] - expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([ + expect(offloadBase64(messages, 1)[0]?.content).toEqual([ nested, - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: OMITTED }, ]) }) }) @@ -109,6 +121,7 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxBytes: 128 * mib, byteQuantum: 64 * mib, + placeholder: () => OMITTED, })[0]?.content expect(project(128)?.filter(block => block.type === 'image')).toHaveLength(128) @@ -124,6 +137,7 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxImages: 600, countQuantum: 20, + placeholder: () => OMITTED, }) expect(projected[0]?.content.filter(block => block.type === 'text')).toHaveLength(20) expect(projected[0]?.content.filter(block => block.type === 'image')).toHaveLength(581) @@ -135,12 +149,135 @@ describe('offloadRequestImagesWithPolicy', () => { representation: 'raw', maxBytes: 3, byteLength: () => 2, + placeholder: () => OMITTED, }) expect(projected[0]?.content).toEqual([ - { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + { type: 'text', text: OMITTED }, image(100), ]) }) + + it('builds a distinct placeholder from each omitted attachment', () => { + const first = image(3) + const second = image(3) + first.attachment = { ...first.attachment, name: 'first.png' } + second.attachment = { ...second.attachment, name: 'second.png' } + const projected = offloadRequestImagesWithPolicy([ + createUserMessage({ content: [first, second], source }), + ], { + representation: 'raw', + maxBytes: 3, + placeholder: ref => `omitted:${ref.name}`, + }) + expect(projected[0]?.content).toEqual([ + { type: 'text', text: 'omitted:first.png' }, + second, + ]) + }) +}) + +describe('model-facing image access', () => { + it('describes the request preview, immutable normalized path, and source uncertainty', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 4_000, + width: 2048, + height: 1536, + name: 'source "map".png', + } + const version = { + variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), + attachment, + access: { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' }, + data: Uint8Array.of(1), + mediaType: 'image/png' as const, + bytes: 1, + width: 923, + height: 692, + depth: 'uchar' as const, + space: 'srgb' as const, + hasAlpha: true, + } + expect(requestImageHandleText(attachment, version)).toBe( + `Image "source \\"map\\".png" (${attachment.attachmentId}); request preview 923x692px.` + + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/.dsh/attachments/v1/objects/bb/object" (2048x1536px, image/png).' + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .png before editing.', + ) + }) + + it('names each occurrence from its own reference when one prepared version is shared', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 4_000, + width: 8, + height: 8, + name: 'second.png', + } + const version = { + variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), + attachment, + data: Uint8Array.of(1), + mediaType: 'image/png' as const, + bytes: 1, + width: 8, + height: 8, + depth: 'uchar' as const, + space: 'srgb' as const, + hasAlpha: false, + } + expect(requestImageHandleText({ ...attachment, name: 'first.png' }, version)) + .toContain('"first.png"') + }) + + it('keeps a useful omission identity with and without a local path', () => { + const ref = { + attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), + mediaType: 'image/jpeg' as const, + bytes: 10, + width: 10, + height: 5, + name: 'photo.jpg', + } + expect(offloadedImageText(ref)).toContain('No local normalized image path is available') + expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' })).toBe( + `[image omitted to fit request image limits; "photo.jpg" (${ref.attachmentId}).` + + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/object" (10x5px, image/jpeg).' + + ' Source dimensions, format, and byte size may differ.' + + ' Copy to a writable path ending in .jpg before editing.]', + ) + }) + + it.each([ + ['image/png', '.png'], + ['image/jpeg', '.jpg'], + ['image/webp', '.webp'], + ['image/gif', '.gif'], + ] as const)('names the writable extension for %s', (mediaType, suffix) => { + const ref = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + mediaType, + bytes: 1, + width: 1, + height: 1, + } + expect(offloadedImageText(ref, { readonlyPath: '/tmp/object' })) + .toContain(`writable path ending in ${suffix}`) + }) + + it('rejects a media type that escaped the closed union at runtime', () => { + const ref = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + mediaType: 'image/tiff' as unknown as ImageMediaType, + bytes: 1, + width: 1, + height: 1, + } + expect(() => offloadedImageText(ref, { readonlyPath: '/tmp/object' })) + .toThrow('unreachable variant in image extension: "image/tiff"') + }) }) describe('projectImagesForTextModel', () => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d8bed47236..31217c1f31 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -335,6 +335,7 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', EncodedImageAttachment: 'attachment.md', + ImageAttachmentAccess: 'attachment.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', RequestImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 06cce11143..0195425c04 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -931,6 +931,11 @@ "symbol": "StoredImageAttachment", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ImageAttachmentAccess", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "ImageRequestPolicy", From 7f4cf99eeb919c1c00950498c80bdeecc20dcae0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 13:11:04 +0800 Subject: [PATCH 04/42] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=AE=BF=E9=97=AE=E4=B8=8E=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-08-21-model-readable-image-paths.i18n.yaml | 4 ++-- .../2026-08-21-model-readable-image-paths.md | 10 ++++++---- ...2026-08-21-model-readable-image-paths.zh.md | 10 ++++++---- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 ++-- docs/subsystems/attachment.md | 1 + docs/subsystems/attachment.zh.md | 1 + docs/subsystems/filesystem.i18n.yaml | 4 ++-- docs/subsystems/filesystem.md | 14 ++++++++++++-- docs/subsystems/filesystem.zh.md | 14 ++++++++++++-- .../attachment-local/README.i18n.yaml | 4 ++-- packages/attachment/attachment-local/README.md | 7 +++---- .../attachment/attachment-local/README.zh.md | 7 +++---- .../attachment/attachment-local/package.json | 3 +++ .../attachment/attachment-local/src/index.ts | 18 +++++++++++------- .../attachment/attachment-local/src/store.ts | 3 +++ .../attachment-local/tests/index.spec.ts | 14 +++++++------- .../attachment-local/tests/store.spec.ts | 5 ++++- .../attachment/attachment-local/tsconfig.json | 1 + packages/attachment/attachment/src/index.ts | 1 + packages/e2b/fs-e2b/tests/filesystem.spec.ts | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 7 +++++++ packages/fs/fs-local/README.i18n.yaml | 4 ++-- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/README.zh.md | 2 +- packages/fs/fs-local/src/index.ts | 4 ++++ packages/fs/fs-local/tests/filesystem.spec.ts | 6 ++++++ packages/fs/fs/README.i18n.yaml | 4 ++-- packages/fs/fs/README.md | 7 ++++--- packages/fs/fs/README.zh.md | 7 ++++--- packages/fs/fs/src/index.ts | 13 +++++++++++++ packages/fs/fs/tests/service.spec.ts | 1 + packages/llm/llm-deepseek/src/index.ts | 3 +++ .../llm/llm-deepseek/tests/adapter.spec.ts | 14 ++++++++++++++ pnpm-lock.yaml | 6 ++++++ 37 files changed, 155 insertions(+), 59 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml index 16831b0ca3..314dabaa74 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.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-21-model-readable-image-paths.md -2026-08-21-model-readable-image-paths.md: 4c2610ad48e47d2642e32ae53048d0897abd64e9 -2026-08-21-model-readable-image-paths.zh.md: 24335f1da99c382291988d5862bdd52e8d5d961c +2026-08-21-model-readable-image-paths.md: 729e6347e8623694e1cc4f4779bf71447006cb08 +2026-08-21-model-readable-image-paths.zh.md: d67817cf699afb6b094524388dd6d5a48a653e90 diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md index 4c2610ad48..729e6347e8 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md @@ -10,7 +10,7 @@ An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable ## Decision -`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute immutable-object path from the resolved `DSH_HOME`, attachment storage version, and validated digest. A request version carries these facts transiently for serialization. A provider without model-readable local storage returns no access facts. +`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute object path from the resolved `DSH_HOME`, attachment storage version, and validated digest, then asks the mounted `ctx.fs` to map that host file into its execution world. `FileSystem.processPathFromHostPath(hostPath)` returns no mapping by default; the host-backed provider returns the absolute process path, while E2B and other remote providers return no access facts. A request version carries available facts transiently for serialization. The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. @@ -18,7 +18,7 @@ Request-size offload requires a per-image placeholder function; the previous sha Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. -Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the provider mounted for the current process. Restoring the same session with a different `DSH_HOME` therefore produces the path that is valid on that host. The attachment object remains immutable; model instructions require a writable copy for modifications. +Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the providers mounted for the current process. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications. ## Alternatives considered @@ -26,12 +26,14 @@ Absolute paths stay out of session events. Model-visible path text is reconstruc **Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact. +**Infer path sharing from the filesystem provider's package or class name.** Provider identity does not establish that a host file is mounted into its execution world. The filesystem provider instead answers the exact mapping question and can support an explicit shared mount without changing the attachment provider. + **Add a dedicated crop or recovery tool.** Standard filesystem and image tools can operate after copying the normalized object. A new tool adds a model schema and access-policy surface without being necessary for path discovery. ## Verification -Package tests cover provider access defaults, local digest-to-path resolution, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. +Package tests cover provider access defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. ## Consequences -The selected model provider receives a host path that was previously local-only. This disclosure is required for the model to operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. A missing local object still fails when a model tool attempts to read it. +When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it. diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md index 24335f1da9..d67817cf69 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md @@ -10,7 +10,7 @@ ## 决策 -`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生不可变对象的绝对路径。请求版本只在序列化期间携带这些事实。没有模型可读本地存储的提供方不返回访问事实。 +`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生对象的绝对路径,再要求当前 `ctx.fs` 把这个宿主文件映射到其执行世界。`FileSystem.processPathFromHostPath(hostPath)` 默认不返回映射。宿主后端返回绝对进程路径,E2B 和其他远程后端不返回访问事实。请求版本只在序列化期间携带当前可用的事实。 共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 @@ -18,7 +18,7 @@ 描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 -绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。因此,同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径。附件对象保持不可变;模型指令要求先复制到可写位置再修改。 +绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行世界中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。 ## 考虑过的替代方案 @@ -26,12 +26,14 @@ **让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。 +**根据文件系统提供方的包名或类名判断是否共享路径。** 提供方身份不能证明宿主文件已挂载到其执行世界。文件系统提供方直接回答具体路径能否映射,也允许未来的显式共享挂载在不修改附件提供方的情况下返回映射。 + **增加专用裁剪或恢复工具。** 把规范化对象复制出来后,标准文件系统与图片工具已经可以处理它。新增工具会增加模型 schema 和访问策略范围,但路径发现不需要它。 ## 验证 -包测试覆盖提供方访问默认值、本地摘要到路径的解析、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 +包测试覆盖提供方访问默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 ## 后果 -所选模型提供方会收到此前只在本地存在的宿主路径。模型需要该信息才能操作存储图片,并且路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。本地对象缺失时,模型工具读取它仍会失败。 +执行世界能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。没有共享挂载的远程执行世界会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ea79995c64..c0ac258326 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: ffea1ee18359fd6f7cc9171d103e143b8bcd9e5b -config-catalog.zh.md: c447104fcb00d0df3625f6cae89ca128bfa44b57 +config-catalog.md: 7af4d4e47866736ca5f100d07dbd04f8341ae4a0 +config-catalog.zh.md: 9e1d3739d25b309172f2a9261cb10e8c5775af12 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ffea1ee183..7af4d4e478 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c447104fcb..9e1d3739d2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -319,7 +319,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:52`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 17c9d1bf47..fc14d236c4 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: fef6b3ad40f424a82049cddc761efa483fdda6f3 -attachment.zh.md: def50f79f33cec9b9262823d481a2d03429f0845 +attachment.md: d855a31c4b463098e30652a30e807d648298c952 +attachment.zh.md: 314a6486266fe27d5e5fda17ae1b0c09ee65c5f3 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index fef6b3ad40..d855a31c4b 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -190,6 +190,7 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise @@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P */ abstract processPath(target: FsTarget): string +/** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ +processPathFromHostPath(hostPath: string): string | undefined + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index ecbb9e83cf..ea5b6f7a0d 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -12,7 +12,7 @@ 每个操作首先将用户提供的路径解析为不透明的后端目标。消费方可以显示 `displayPath`,但禁止解析 `targetKey`(一个品牌化的不透明 id),也不得假设它是本地绝对路径。 -与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径,`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI,`contains(parent, child)` 则检查规范化身份相等或后代包含关系。 +与文件系统共享执行世界的消费方通过提供方获取跨能力坐标,而不是解释该身份:`processPath(target)` 返回子进程可以打开的规范化绝对路径;`processPathFromHostPath(hostPath)` 只在该执行世界共享相应宿主文件时映射其绝对路径;`fileUrl(target)` 返回采用提供方平台语法的 `file:` URI;`contains(parent, child)` 检查规范化身份相等或后代包含关系。 ```ts type-equiv /** @@ -275,7 +275,7 @@ type FsErrorCode = ## 服务与插件 -`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`processPathFromHostPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-observation-policy` **不注册服务**。它通过 `fs/*` 事件门禁添加策略,根据未见、缺失或存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取、写入或编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 @@ -313,6 +313,16 @@ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): P */ abstract processPath(target: FsTarget): string +/** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ +processPathFromHostPath(hostPath: string): string | undefined + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index a82a48a906..dd994eee03 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: e23fe75a32a873f3f3762c14060c92c66be3a12a -README.zh.md: 646d1d8f26df382be7b0c2c18f2b6ed72661f3a4 +README.md: c93f32ede4599d8e07536062dd7d44134bea16b0 +README.zh.md: f06c3aeec937e0eb09ea0aa2ef25d41119e54aa8 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index e23fe75a32..c93f32ede4 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, a synced temporary file, an atomic exclusive hard-link publish, owner-read-only object permissions, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the absolute normalized-object path from that reference and the current provider root. The path is host-specific, read-only, and absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the normalized object's absolute host path and asks the mounted `ctx.fs` to map that file into its execution world. Host-backed filesystems return a read-only process path; remote filesystems without a shared mount return no path. The path is absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience -Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, current read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. +Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, available read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. #### KV Cache effect @@ -21,6 +21,5 @@ Normalization and request projection are deterministic. An unchanged attachment ## Known Limitations and Deferred Work - Objects are retained indefinitely; reference-aware garbage collection is deferred. -- The local backend assumes the host and provider adapter share this filesystem service. - Animated GIF sources keep only their first frame; animation is outside the version-one image contract. - The normalization and request encoders are pinned by the installed sharp/libvips build; an encoder or transform-version upgrade re-addresses future normalized attachments or request variants while existing objects stay valid. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 646d1d8f26..f06c3aeec9 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、经过同步的临时文件、原子且排他的硬链接发布、仅所有者可读的对象权限,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 根据该引用和当前提供方根目录派生规范化对象的绝对路径。该路径属于当前宿主,只供读取,不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 先派生规范化对象的绝对宿主路径,再要求当前 `ctx.fs` 把该文件映射到其执行世界。宿主文件系统返回只读进程路径;没有共享挂载的远程文件系统不返回路径。该路径不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 -该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 +该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前可用的只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 #### KV 缓存影响 @@ -21,6 +21,5 @@ ## 已知限制与待完成工作 - 对象会无限期保留;基于引用的垃圾回收尚未实现。 -- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 - 动态 GIF 源图只保留首帧;动画在版本一图片契约之外。 - 规范化和请求版本编码器由安装的 sharp/libvips 构建钉定;编码器或变换策略版本升级会让未来的规范化附件或请求变体产生新地址,已有对象保持有效。 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 194112be29..cb2472f62a 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -23,6 +23,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -33,6 +34,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 31d9f38014..6a22038154 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -13,6 +13,7 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-fs' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' @@ -208,8 +209,10 @@ export class LocalAttachmentStore extends AttachmentStore { return readImageFile(this.root, ref, signal) } - override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess { - return { readonlyPath: normalizedImagePath(this.root, ref) } + override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { + const hostPath = normalizedImagePath(this.root, ref) + const readonlyPath = this.ctx.get('fs')?.processPathFromHostPath(hostPath) + return readonlyPath === undefined ? undefined : { readonlyPath } } override async readImageRequest( @@ -235,15 +238,16 @@ export class LocalAttachmentStore extends AttachmentStore { operation = undefined } if (operation === undefined) { - const shared = new SharedRequest(sharedSignal => this.compression.run(async () => ({ - ...await readRequestImageFile( + const shared = new SharedRequest(sharedSignal => this.compression.run(async () => { + const request = await readRequestImageFile( this.root, stored ?? await this.readImage(ref, sharedSignal), policy, sharedSignal, - ), - access: this.imageAccess(ref), - }))) + ) + const access = this.imageAccess(ref) + return { ...request, ...(access === undefined ? {} : { access }) } + })) operation = shared this.requestInflight.set(key, shared) void shared.promise.finally(() => { diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 266d4f8e34..f5463a478f 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -222,6 +222,9 @@ export async function commitPreparedImageFile( const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } + // The hard link and staging entry share one inode, so this applies to both + // names until cleanup and also enforces read-only mode on deduplicated objects. + await chmod(target, 0o400) // Persist the target entry and close a concurrent bucket-creation window // before the reference can reach a session checkpoint. The dedup path // repeats both syncs because it may observe another writer's link before diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index e5c409b1d6..ba18777c75 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -45,12 +46,7 @@ describe('local attachment service', () => { width: 1, height: 1, } - expect(service.imageAccess(ref).readonlyPath).toBe(join( - service.root, - 'objects', - 'aa', - 'a'.repeat(64), - )) + expect(service.imageAccess(ref)).toBeUndefined() expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') })) .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) }) @@ -65,8 +61,10 @@ describe('local attachment service', () => { it('saves and reads through the service boundary', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-')) + const ctx = new Context() + new LocalFileSystem(ctx, { cwd: dshHome, diffBasisMaxBytes: 10 * 1024 * 1024 }) try { - const service = new LocalAttachmentStore(new Context(), { dshHome }) + const service = new LocalAttachmentStore(ctx, { dshHome }) const data = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', @@ -74,6 +72,8 @@ describe('local attachment service', () => { const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) const access = service.imageAccess(ref) + expect(access).toBeDefined() + if (access === undefined) throw new Error('expected a host-path mapping from fs-local') expect(access.readonlyPath).toBe(join( dshHome, 'attachments', diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index ad29f856ec..64cca74b7e 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -132,9 +132,12 @@ describe('local attachment store', () => { expect(second.attachmentId).toBe(first.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) if (process.platform !== 'win32') { - expect((await stat(object)).mode & 0o777).toBe(0o600) + expect((await stat(object)).mode & 0o777).toBe(0o400) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) } + await chmod(object, 0o600) + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + if (process.platform !== 'win32') expect((await stat(object)).mode & 0o777).toBe(0o400) await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json index 3ac8b3fcff..a5fff7a98a 100644 --- a/packages/attachment/attachment-local/tsconfig.json +++ b/packages/attachment/attachment-local/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../attachment" }, + { "path": "../../fs/fs" }, { "path": "../../util/home-paths" }, { "path": "../../runtime-diagnostics/invariants" } ] diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index e10ce2d214..f72b0b2052 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -113,6 +113,7 @@ export abstract class AttachmentStore extends Service { * Resolve provider-specific model-tool access without adding host facts to session history. * @param ref - durable normalized attachment reference. * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @throws an AttachmentError when the durable reference is invalid. */ imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { void ref diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index fa75709ec2..97e549e84b 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -360,6 +360,7 @@ describe('E2BFileSystem identity, metadata, and reads', () => { const outside = await fs.resolve('/outside.ts') expect(fs.processPath(nested)).toBe('/workspace/nested/multibyte # file.ts') + expect(fs.processPathFromHostPath('/Users/alice/.dsh/attachments/object')).toBeUndefined() expect(fs.fileUrl(nested)).toBe('file:///workspace/nested/multibyte%20%23%20file.ts') expect(fs.contains(workspace, workspace)).toBe(true) expect(fs.contains(workspace, nested)).toBe(true) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 197ff6fadd..57d9ab16d6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -454,6 +454,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Resolve provider-specific model-tool access without adding host facts to session history.', parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }], returns: 'current-provider access facts, or undefined when this backend exposes no local path.', + throws: ['an AttachmentError when the durable reference is invalid.'], }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', @@ -769,6 +770,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'target', description: 'the resolved target whose process path is required.' }], returns: 'an absolute path in the backend\'s execution world.', }, + { + signature: 'processPathFromHostPath(hostPath: string): string | undefined', + description: 'Map an absolute path from the harness host into this filesystem\'s execution world when both paths identify the same file. The base provider exposes no mapping; host-backed or explicitly shared backends override it.', + parameters: [{ name: 'hostPath', description: 'absolute path in the harness host filesystem.' }], + returns: 'the process path for the same file, or undefined when this execution world cannot read that host file.', + }, { signature: 'abstract fileUrl(target: FsTarget): string', description: 'Return the canonical `file:` URI for a target in this filesystem\'s execution world. Backends own URI encoding because the host platform may differ from the execution platform.', diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 68d3340327..02d7e4b704 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/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/fs/fs-local/README.md -README.md: 4d5c42945b86ccdc8b041d9d7f99a067ab9a37f5 -README.zh.md: 51f417f73294b7497563fece168e5a5d44b73421 +README.md: ae6d2f582abfdbdfcf922899ffe485691167c97e +README.zh.md: 161e4ee9e5b3dbd0f7efc64692e10dc522538034 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 4d5c42945b..ae6d2f582a 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## Behavior - **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. `opts.signal` is checked before and after local resolution, while a remote sibling backend may use it to abort its round-trip. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path. -- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. +- **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `processPathFromHostPath` accepts absolute host paths because this backend shares the host filesystem, `fileUrl` encodes target paths through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. - **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 51f417f732..161e4ee9e5 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## 行为 - **`resolve(path, opts?)`**:相对 `path` 在调用方提供 `opts.cwd` 时以该值为基准解析(面向模型的工具会传入调用 agent(智能体)的会话 cwd;见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md)),否则以 `config.cwd` 为基准(默认 `process.cwd()`);绝对 `path` 会忽略两者。`opts.signal` 会在本地解析前后检查,远程同级后端则可以用它中止往返。`targetKey` 是文件的 `realpath`,因此经符号链接到达同一文件的两个输入路径会共享一个身份,写入/编辑落在链接目标上,同时保留链接。尚不存在的路径在父目录存在时使用 realpath 后的父目录加 basename;只有父目录无法解析时才回退到绝对路径。`displayPath` 是绝对但未经解析的路径。 -- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 +- **执行世界坐标**:`processPath` 公开目标的规范化宿主路径。由于该后端共享宿主文件系统,`processPathFromHostPath` 接受绝对宿主路径。`fileUrl` 通过 Node 的平台感知 URL 转换对目标路径编码。`contains` 使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此异步探测进行期间发生的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以自行限制保留量。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 - **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。 diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 661ef236b8..7c50532a0e 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -114,6 +114,10 @@ export class LocalFileSystem extends FileSystem { return String(target.targetKey) } + override processPathFromHostPath(hostPath: string): string | undefined { + return isAbsolute(hostPath) ? resolve(hostPath) : undefined + } + override fileUrl(target: FsTarget): string { return pathToFileURL(this.processPath(target)).href } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 1977f438b9..b791b424fb 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -59,6 +59,12 @@ describe('registration', () => { await bareFiber.dispose() }) + it('maps only absolute host paths into its process path namespace', () => { + const path = join(dir, 'image.png') + expect(fs.processPathFromHostPath(path)).toBe(path) + expect(fs.processPathFromHostPath('image.png')).toBeUndefined() + }) + it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => { const maxDiffBasisBytes = Math.min( bufferConstants.MAX_LENGTH, diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 5736cd6c81..c001d6a107 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/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/fs/fs/README.md -README.md: 7bde7d4d64005a6bdd0f0ac974bf45e5450e4207 -README.zh.md: c4366dc1805938a7020310f4cf627b9072a75cd5 +README.md: b6255a385daf9185ccbfb45e4d1896ad2e8a2c9e +README.zh.md: e932b3144430618e2ec7458491b26d43c517024f diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 7bde7d4d64..b6255a385d 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. +The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, map shared host files, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -17,12 +17,13 @@ This package owns the Service Definition and provider contract layer of the four ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements twelve primitives. +A backend subclasses `FileSystem` and exposes thirteen primitives. | Member | Semantics | |---|---| | `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default), while `opts.signal` aborts a backend round-trip. Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | | `processPath(target)` | Return the canonical absolute path that a subprocess in this provider's execution world can open. This is intentionally distinct from opaque `targetKey`. | +| `processPathFromHostPath(hostPath)` | Return this execution world's process path for the same absolute host file when the backend shares it. The base implementation returns `undefined`; host-backed or explicitly mapped backends override it. | | `fileUrl(target)` | Return the canonical `file:` URI in the execution world's platform syntax. The backend, not the host process, owns encoding. | | `contains(parent, child)` | Test canonical identity/descendant containment without exposing or parsing target keys. Both targets come from this provider. | | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | @@ -61,6 +62,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **Text-only mutations by contract** — text reads and both mutations reject binary/non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the one raw-byte primitive, and binary-safe mutations remain a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). -- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). +- **Thirteen primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). - **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index c4366dc180..e932b31444 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、有界读取原始字节、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 +**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、映射共享的宿主文件、检查包含关系、完整或流式读取文本、有界读取原始字节、检查或列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选**接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 本包拥有四层文件系统栈中的 Service Definition 和提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md)): @@ -17,12 +17,13 @@ ## 服务 API(`ctx.fs`) -后端继承 `FileSystem` 并实现十二个原语。 +后端继承 `FileSystem` 并公开十三个原语。 | 成员 | 语义 | |---|---| | `resolve(path, opts?)` | 把路径解析为稳定的 `FsTarget`(不透明 `targetKey`、`displayPath`)。`opts.cwd` 是相对 `path` 解析所依据的基准(调用方提供其会话工作区;绝对路径忽略该值;省略时使用后端默认值),`opts.signal` 则中止后端往返。该方法是异步的,因为远程后端可能需要 I/O。经不同路径到达的同一文件必须产生相同 `targetKey`。 | | `processPath(target)` | 返回该提供方执行世界中的子进程可以打开的规范化绝对路径。该路径有意与不透明的 `targetKey` 分离。 | +| `processPathFromHostPath(hostPath)` | 当后端共享同一个宿主文件时,返回该文件在当前执行世界中的进程路径。基类返回 `undefined`,宿主后端或显式映射宿主文件的后端负责覆盖。 | | `fileUrl(target)` | 返回采用执行世界平台语法的规范化 `file:` URI。编码由后端而非宿主进程负责。 | | `contains(parent, child)` | 在不公开或解析目标 key 的情况下,检查规范化身份相等或后代包含关系。两个目标都来自该提供方。 | | `stat(target, signal?)` | 返回 `FsInfo` 元数据(`version`、`type`、可选 `size`);目标不存在时返回 `undefined`。绝不返回内容。 | @@ -61,6 +62,6 @@ ## 已知限制与延期工作 - **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作仍是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md)有意延期的工作。 -- **只有十二个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 +- **只有十三个原语**:没有删除、重命名或移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.zh.md))。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 8ecd03c714..e32890d732 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -125,6 +125,19 @@ export abstract class FileSystem extends Service { */ abstract processPath(target: FsTarget): string + /** + * Map an absolute path from the harness host into this filesystem's + * execution world when both paths identify the same file. The base provider + * exposes no mapping; host-backed or explicitly shared backends override it. + * @param hostPath - absolute path in the harness host filesystem. + * @returns the process path for the same file, or undefined when this + * execution world cannot read that host file. + */ + processPathFromHostPath(hostPath: string): string | undefined { + void hostPath + return undefined + } + /** * Return the canonical `file:` URI for a target in this filesystem's * execution world. Backends own URI encoding because the host platform may diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 121901dc95..6fbe1268af 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -88,6 +88,7 @@ describe('FileSystem provider seam', () => { await ctx.plugin(FakeFileSystem) const fs = ctx.fs as FakeFileSystem expect(fs.sandboxMode).toBeUndefined() + expect(fs.processPathFromHostPath('/host/file')).toBeUndefined() fs.files.set('a.txt', 'hi') const target = await fs.resolve('a.txt') expect((await fs.stat(target))?.type).toBe('file') diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 747e37ca4c..4f5864aa6d 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -195,6 +195,9 @@ export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] { const seen = new Set() return (models ?? DEFAULT_MODELS).map((model) => { + if (Object.hasOwn(model, 'imageDetail')) { + throw new Error('llm-deepseek: catalog model imageDetail is no longer supported; use imagePixelBudget') + } if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty') if (model.name !== undefined && model.name.length === 0) { throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9408f1595b..362a90dadb 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1895,6 +1895,20 @@ describe('plugin registration and config', () => { expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) }) + it('rejects the removed imageDetail model setting through schema and direct construction', async () => { + const legacyModel = { id: 'vision', inputModalities: ['image'], imageDetail: 'low' } as unknown as + LlmDeepSeek.DeepSeekCatalogModel + expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [legacyModel], + })).rejects.toThrow(/imageDetail is no longer supported/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) .toThrow(/maxTokens must be a positive integer/) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e763188fd0..eebeb12111 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1136,6 +1136,12 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths From cd6941d5d74405f518b62afac2c05c5e7c890e9d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 24 Aug 2026 14:39:56 +0800 Subject: [PATCH 05/42] Revert "perf(ci): shorten native Windows coverage critical path" --- ...-31-coverage-exempt-heavy-suites.i18n.yaml | 4 +- ...2026-07-31-coverage-exempt-heavy-suites.md | 14 +-- ...6-07-31-coverage-exempt-heavy-suites.zh.md | 14 +-- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +- ...26-08-08-native-windows-pull-request-ci.md | 14 +-- ...08-08-native-windows-pull-request-ci.zh.md | 14 +-- ...8-18-in-job-partitioned-coverage.i18n.yaml | 4 +- .../2026-08-18-in-job-partitioned-coverage.md | 14 +-- ...26-08-18-in-job-partitioned-coverage.zh.md | 14 +-- ...ws-blocks-pull-request-aggregate.i18n.yaml | 6 -- ...e-windows-blocks-pull-request-aggregate.md | 31 ------ ...indows-blocks-pull-request-aggregate.zh.md | 31 ------ .github/workflows/ci.yml | 46 ++++----- .../tests/compile/transform-corpus.spec.ts | 96 ++----------------- scripts/ci-workflow.spec.ts | 11 +-- scripts/coverage-exempt.ts | 6 -- scripts/oxlint-contract.spec.ts | 41 ++------ scripts/run-gates.spec.ts | 7 +- scripts/run-gates.ts | 9 +- 19 files changed, 80 insertions(+), 300 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md delete mode 100644 .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml index dc243ba95c..78ff70c1c1 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.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/process/2026-07-31-coverage-exempt-heavy-suites.md -2026-07-31-coverage-exempt-heavy-suites.md: 35642c41c0140b5a39be7da4668b33b70f858a85 -2026-07-31-coverage-exempt-heavy-suites.zh.md: cefade080581e4c20a71269bef638e12559153ae +2026-07-31-coverage-exempt-heavy-suites.md: 1f468a69321b451593a9279cfebc1b457fb08a47 +2026-07-31-coverage-exempt-heavy-suites.zh.md: 7e519f44c8321b6b99c04c6af56c4cfa5b641663 diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md index 35642c41c0..1f468a6932 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md @@ -10,8 +10,6 @@ The CI coverage lane (`check:ci:coverage`) had its wall clock pinned by a handfu The decisive waste: the instrumentation tax these suites paid contributed **nothing** to the per-file 100% thresholds — the measured code they execute in-process is either outside the threshold scope already or independently fully covered by other suites. Running them instrumented traded lane time for zero information. -The Web Worker transform corpus exposed the same waste on native Windows: `transform-corpus.spec.ts` spent 279 seconds inside one 442-second coverage partition while the other seven partitions settled in 110–161 seconds. Its real checker runs package source only in a spawned Node process, outside the parent Vitest worker's v8 coverage session, so the slow partition produced no threshold data from that work. - ## Decision The `ci-coverage` aggregate splits into two parallel gates; every test still runs, and only the heavy suites stop paying the instrumentation tax: @@ -19,12 +17,10 @@ The `ci-coverage` aggregate splits into two parallel gates; every test still run - **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged. - **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole. -Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. Linux overlaps four partition children, two exempt workers, and up to eight corpus children, so this combined fan-out is the first check if that lane regresses. Native Windows runs the exempt gate after the instrumented merge, while the lightweight observational inventory overlaps the exempt work, so the full-corpus child does not compete with sixteen coverage processes. The Oxlint contract suite atomically publishes scanner-valid temporary package probes and hides its script-only probes from glob discovery. +Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. `scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift. -`transform-corpus.spec.ts` discovers the complete built-bundle set once, assigns every path to exactly one of up to eight non-empty Node-loader children, and asserts the shard union before launch. `client-runtime` follows `acp-snapshot` for its pinned Vitest-state exemption, while `win32-process` follows `sandbox-windows-acl` for its pinned Koffi exemption. - ### The roster, reconciled entry by entry A suite contributes to coverage exactly when it executes measured files in-process (`coverage.include` spans the package src trees). The current roster, audited: @@ -34,7 +30,6 @@ A suite contributes to coverage exactly when it executes measured files in-proce | All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with | | tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) | | `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts`, `scripts/translation-pairing-merge.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry | -| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | None — its package-source imports and the complete bundle sweep run in a spawned Node process | The Web Worker runtime's in-process unit suites carry its source coverage | ### Membership contract @@ -54,22 +49,15 @@ Coverage-result invariance therefore does not rest on humans maintaining the ros - **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it. - **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction. - **Cross-runner sharding (`--shard` + blob merge).** Rejected because a matrix, artifact pipeline, and merge job would add a second workflow topology. The selected [in-job partitioning](2026-08-18-in-job-partitioned-coverage.md) uses Vitest shards only as local single-worker processes inside the existing job. -- **Keep the transform corpus in one Node process.** Rejected because its serial loader becomes the Windows heavy gate's longest tail under host contention. Eight local children retain the same file set, per-file oracle, loader-sensitive affinities, and one blocking Vitest verdict. - **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal. ## Verification Measured on CI (16-core runner): the gate segment went from 424 seconds to the two gates in parallel — `test:coverage` 95.9 s + `test:coverage-exempt-heavy` 71.1 s — with the lane converging on the slower at about 96 seconds; the instrumented gate reported zero threshold errors both before and after the split. `vitest list` verifies the env toggle adds and removes exactly the exempt set; `run-gates.spec.ts` covers the aggregate graph construction. -The Web Worker corpus entry is pinned by a partitioned aggregate that runs all 15,250 tests and reports 100% for 45,959 statements, 28,116 branches, 9,781 functions, and 40,550 lines. A focused instrumented corpus run records no package source from its child process; the paired list check proves the spec is absent from the instrumented inventory and present in the uninstrumented inventory. - -The eight-child corpus run checks the same 239 native Windows bundles with 234 exact export matches, four pinned loader exemptions, one sentinel refusal, and no drift. The ARM64 VM measures 25.44 seconds for the sharded Vitest path versus 29.59 seconds for the unsharded checker; the complete x64 job remains the contended-host timing proof. - ## Consequences - The exempt suites execute without adding instrumentation cost to the thresholded gate; partitioned wall-clock measurements belong to the [in-job partitioning decision](2026-08-18-in-job-partitioned-coverage.md). -- Native Windows schedules the exempt suites after instrumented coverage and overlaps them with observational checks; Linux retains the parallel coverage split. -- The corpus suite uses up to eight non-empty child Node loaders but emits one blocking test result; its affinity roster is part of the exemption oracle and must move with affected bundles. - `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through. - Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently. - The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail. diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md index cefade0805..7e519f44c8 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md @@ -10,8 +10,6 @@ CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试 关键的浪费在于:这些套件缴纳的插桩税对 per-file 100% 阈值**没有任何贡献**——它们进程内执行的被度量代码,要么本来就不在阈值口径内,要么已由其他套件独立满覆盖。继续在插桩下运行它们,纯粹是用 lane 时长换零信息。 -Web Worker 转换语料库在原生 Windows 上暴露了同一类浪费:`transform-corpus.spec.ts` 在一个 442 秒的覆盖率分区中占用 279 秒,而其余七个分区在 110–161 秒内完成。它的真实检查器只在 spawn 的 Node 子进程中运行包源码,处于父 Vitest worker 的 v8 覆盖率会话之外,因此这个慢分区没有从该工作中产生任何阈值数据。 - ## Decision `ci-coverage` 聚合拆成两个并行 gate,全部测试仍然执行,只有重型套件不再交插桩税: @@ -19,12 +17,10 @@ Web Worker 转换语料库在原生 Windows 上暴露了同一类浪费:`trans - **插桩 gate**(`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1`,`vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。 - **无插桩 gate**(`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。 -Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。Linux 会让 4 个分区子进程、2 个豁免 worker 与最多 8 个语料库子进程重叠,因此该通道变慢时应先检查这组并发。原生 Windows 在插桩报告合并后运行豁免门禁,同时让轻量观测性清单与豁免工作重叠,因此完整语料库子进程不会与 16 个覆盖率进程争用资源。Oxlint 约定套件会原子发布满足源码扫描要求的包内临时探针,并把只属于脚本的探针对 glob 发现隐藏。 +Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。 `scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格约定与 filter/exclude 配对,防止两侧漂移。 -`transform-corpus.spec.ts` 只发现一次完整的已构建 bundle 集合,把每条路径恰好分配给最多 8 个非空 Node loader 子进程之一,并在启动前断言分片并集。`client-runtime` 会为固定的 Vitest 状态豁免跟在 `acp-snapshot` 之后,`win32-process` 则会为固定的 Koffi 豁免跟在 `sandbox-windows-acl` 之后。 - ### 豁免名单与逐项对账 一个套件对覆盖率有贡献,当且仅当它在进程内执行了被度量的文件(`coverage.include` = 包 src 树)。现行名单逐项核对: @@ -34,7 +30,6 @@ Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分 | typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded(`vitest.config.ts`),本不在阈值口径内 | | 其中 tools-catalog.spec 额外 import | `typert-registry`、`tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) | | `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts`、`scripts/translation-pairing-merge.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 | -| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | 无——包源码 import 与完整 bundle 扫描都在 spawn 的 Node 子进程中运行 | Web Worker runtime 的进程内单元套件承担其源码覆盖率 | ### 成员资格约定 @@ -54,22 +49,15 @@ per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默 - **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。 - **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。 - **跨 runner 分片(`--shard` + blob 合并)。** 不予采用,因为 matrix、产物流水线和合并 job 会引入第二套工作流拓扑。所选的 [job 内分区](2026-08-18-in-job-partitioned-coverage.zh.md)只把 Vitest shard 用作既有 job 内的本地单 worker 进程。 -- **让转换语料库保留在一个 Node 进程中。** 不予采用,因为串行 loader 在宿主争用下成为 Windows 重型门禁的最长尾部。八个本地子进程保留相同文件集、逐文件判定器、对 loader 敏感的亲和顺序,以及一个阻断性 Vitest 判定。 - **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。 ## Verification CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate 并行 `test:coverage` 95.9 秒 + `test:coverage-exempt-heavy` 71.1 秒,lane 收敛于较慢者约 96 秒;拆分前后插桩 gate 阈值错误均为零。`vitest list` 验证 env 开关两态恰好增删豁免集;`run-gates.spec.ts` 覆盖聚合图构造。 -Web Worker 语料库条目由分区聚合固定:它执行全部 15,250 个测试,并对 45,959 条语句、28,116 个分支、9,781 个函数和 40,550 行报告 100%。聚焦的插桩语料库运行不会记录其子进程中的包源码;配对名单检查证明该 spec 不在插桩清单中,但存在于无插桩清单中。 - -八子进程语料库运行检查相同的 239 个原生 Windows bundle,得到 234 个精确 export 匹配、四个固定 loader 豁免、一次 sentinel 拒绝和零漂移。ARM64 虚拟机上,分片 Vitest 路径耗时 25.44 秒,未分片检查器耗时 29.59 秒;完整 x64 job 仍负责证明宿主争用下的耗时。 - ## Consequences - 豁免套件在执行时不会向阈值门禁叠加插桩开销;分区墙钟数据由 [job 内分区决策](2026-08-18-in-job-partitioned-coverage.zh.md)负责记录。 -- 原生 Windows 在插桩覆盖率后调度豁免套件,并让它们与观测性检查重叠;Linux 保留并行覆盖率拆分。 -- 语料库套件使用最多 8 个非空 Node loader 子进程,但只产生一个阻断性测试结果;其亲和名单属于豁免判定器,受影响 bundle 移动时必须同步更新。 - `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。 - 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。 - 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index ea0f2ca087..78f432ef3b 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: d3b37bdcc19b7b06aee2aa4a067cfd317617ab67 -2026-08-08-native-windows-pull-request-ci.zh.md: 01edfece893d1d6f4cbb14a7d061e372313327f3 +2026-08-08-native-windows-pull-request-ci.md: 1f8bf7c9e5249ce218fd0d169ed82008c2dbbd36 +2026-08-08-native-windows-pull-request-ci.zh.md: efe044e601aebc92f5d9446a1a683c935dcd783b diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index d3b37bdcc1..1f8bf7c9e5 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) ## Problem -The pull-request Windows verdict needs both a fast win32 toolchain signal and a real Windows-kernel result. Wine provides the fast signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. +The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator. @@ -14,13 +14,13 @@ A coverage audit found that stale branch state had restored temporary exclusions The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts a separate `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. +Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup` standalone mode, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. -The native job retains its own unmasked result. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes that result a dependency of `all checks passed`; this note owns the job's execution topology and complete inventory. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. +The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. -The 16-core lane admits eight concurrent outer gates. Workspace build, production-site validation, and sixteen-process instrumented coverage start immediately. Exempt-heavy coverage needs the build and waits for the merged coverage verdict, so its four Vitest workers and up to eight corpus children do not compete with the partition phase. The lightweight observational inventory also waits for coverage, then overlaps the exempt work; temporary package probes are atomically published with scanner-valid contents, while script-only probes use hidden filenames. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane admits four concurrent outer gates. Workspace build, production-site validation, and instrumented coverage start immediately. Exempt-heavy coverage waits for the build to pass, so its temporary Oxlint contract probes cannot race source compilation. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`. The initial phase therefore has about ten active execution units; after build, starting exempt-heavy while build leaves keeps the peak near eleven when site and instrumented coverage are still running. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. -The 16-core allocation is the measured capacity point for this inventory. Exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits, while separate single-worker child processes retain process isolation. Sixteen-shard samples and the final hosted run complete instrumented coverage in 112.66–131.33 seconds; the job gives that phase the host before starting exempt work. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. @@ -36,6 +36,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Alternatives considered +**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path. + **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. **Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism. @@ -48,7 +50,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine preserves a fast early signal and its stable job identity. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes `all checks passed` wait for both Wine and native Windows, so branch protection consumes their combined verdict through one stable required check. +Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result. Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 01edfece89..efe044e601 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -拉取请求的 Windows 判定同时需要快速的 win32 工具链信号与真实 Windows 内核结果。Wine 提供快速信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 +拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 覆盖率审计发现,陈旧分支状态恢复了针对受支持 LSP 源码的临时排除项。因此,原生 Windows 需要按同一逐文件 100% 阈值执行完整的受支持源码清单,而不能依赖缩小后的平台专用分母。 @@ -14,13 +14,13 @@ Status: implemented [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个单独的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 的 standalone 模式提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 -原生作业保留自身未被掩盖的结果。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让该结果成为 `all checks passed` 的依赖项;本文负责该作业的执行拓扑与完整清单。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 +原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 -16 核通道最多同时运行 8 道外层门禁。工作区构建、生产网站验证与 16 进程插桩覆盖率会立即启动。豁免重型覆盖率依赖构建并等待覆盖率报告合并,因此其 4 个 Vitest worker 与最多 8 个语料库子进程不会和分区阶段争用资源。轻量观测性清单同样等待覆盖率,随后与豁免工作重叠;包内临时探针会以满足源码扫描要求的完整内容原子发布,只属于脚本的探针则使用隐藏文件名。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证与插桩覆盖率会立即启动。豁免重型覆盖率等待构建通过后再启动,使其临时 Oxlint 约定探针不会与源码编译竞态。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker。因此初始阶段约有 10 个活动执行单元;构建结束并启动豁免重型门禁后,如果网站与插桩覆盖率仍在运行,峰值约为 11 个。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 -16 核配置是这项清单经实测选定的容量规格。在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出,而相互独立的单 worker 子进程保留进程隔离。16 分片样本与最终托管运行会在 112.66–131.33 秒内完成插桩覆盖率;作业会先把宿主资源交给该阶段,再启动豁免工作。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 @@ -36,6 +36,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 曾考虑的替代方案 +**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 + **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 **将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 @@ -48,7 +50,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 保留快速的早期信号与稳定作业身份。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让 `all checks passed` 同时等待 Wine 与原生 Windows,因此分支保护通过一个稳定的必需检查采用二者的合并判定。 +Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。 尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index d8ee642db9..b48f880c61 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: 8d8684b6299f4c2dc359ced09048d0b6acb9ed42 -2026-08-18-in-job-partitioned-coverage.zh.md: 4781b5e0abb8dc4dde8004b08f5a5f105ef50b80 +2026-08-18-in-job-partitioned-coverage.md: 532c145f5b66bd6574f9ee167c12c739fd4d7fa9 +2026-08-18-in-job-partitioned-coverage.zh.md: dc8a089a7c089338b775e49fdfe67f134f077704 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index 8d8684b629..532c145f5b 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -12,13 +12,13 @@ The optimization must retain every test and the merged per-file 100% thresholds. ## Decision -The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 16; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate. +The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 8; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work. When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker and one `--shard=/` option. Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process. The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Build, production-site validation, and instrumented coverage start immediately on native Windows. The exempt gate needs the build and waits for instrumented coverage to settle, so its full-corpus children and temporary Oxlint probes do not compete with the sixteen partitions; it then receives four workers from the budget of 12. The observational inventory also waits for instrumented coverage, then overlaps the exempt gate within an eight-worker outer budget. Ordering uses `after`, so both groups still run after an instrumented failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. Build, production-site validation, and instrumented coverage start immediately; exempt-heavy coverage starts only after build passes, preventing its temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics @@ -30,9 +30,7 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al `scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures. -Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds. Sixteen is the fixed Windows count. The exempt gate waits for their merged verdict, so the partition phase overlaps only build and production-site validation: at most eighteen active execution units on a 16-core runner, rather than adding exempt workers to that peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. - -The native ARM64 VM runs the full transform corpus in 29.59 seconds without coverage partitions and in 25.44 seconds through the eight-child Vitest path. A concurrent self-hosted x64 job stretched the former serial test to 279.13 seconds while one instrumented partition reached 442.45 seconds. The Windows graph separates the partition and exempt phases before applying its fixed sixteen-way coverage fan-out. +Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds, but the sixteen-way schedule could put more than twenty active execution units beside build and exempt coverage on a 16-core runner. Eight partitions keep separate-process isolation while accepting a longer feedback path for a materially lower peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. ## Alternatives considered @@ -40,18 +38,14 @@ The native ARM64 VM runs the full transform corpus in 29.59 seconds without cove **Raise the Vitest worker count inside one instrumented process.** Rejected because completed Windows trials at higher fan-out exposed worker exits, fixture instability, and Node 24 CJS lexer failures. Separate single-worker processes preserve isolation while still executing the selected partitions concurrently. -**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's sixteen-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. +**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. **Apply thresholds independently in each partition.** Rejected because every partition intentionally sees only part of the suite and would report false uncovered files. Threshold ownership belongs to the merged report. -**Overlap the Windows exempt gate with instrumented partitions.** Rejected because the full-corpus checker is fast in isolation but multiplies under partition contention. The post-coverage phase uses available workers for the exempt and observational checks without changing either verdict. - ## Consequences Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution. Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one. -Windows uses two resource phases inside the same job: sixteen isolated coverage processes through the merged threshold verdict, then the four-worker exempt gate beside lightweight observational checks. - Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index 4781b5e0ab..dc8a089a7c 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 16;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁。 +普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 8;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁与插桩工作并排运行。 启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker,并各自接收一个 `--shard=/` 选项。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 上的构建、生产网站验证与插桩覆盖率会立即启动。豁免门禁要求构建通过,并等待插桩覆盖率结算,因此其完整语料库子进程和临时 Oxlint 探针不会与十六个分区争用资源;随后它从 12 的预算中获得 4 个 worker。观测性清单也等待插桩覆盖率,然后在八 worker 的外层预算内与豁免门禁重叠。该顺序使用 `after`,因此插桩失败后两组检查仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。构建、生产网站验证与插桩覆盖率会立即启动;豁免重型覆盖率只在构建通过后启动,避免其临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 @@ -30,9 +30,7 @@ Status: implemented `scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。 -已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒。Windows 固定使用 16 个分区。豁免门禁等待其合并判定,因此分区阶段只与构建和生产网站验证重叠:16 核运行器上最多有 18 个活动执行单元,不会再把豁免 worker 加入该峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 - -原生 ARM64 虚拟机在没有覆盖率分区时用 29.59 秒运行完整转换语料库,通过八子进程 Vitest 路径时用 25.44 秒。一个并发运行的自托管 x64 job 把此前的串行测试拉长到 279.13 秒,同时一个插桩分区达到 442.45 秒。Windows 门禁图先分离分区阶段与豁免阶段,再应用固定的 16 路覆盖率扇出。 +已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒,但 16 路调度与构建、豁免覆盖率并行时,会在 16 核运行器上形成超过 20 个活动执行单元。8 个分区继续保留独立进程隔离,同时接受更长的反馈路径,以显著降低峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 ## 曾考虑的替代方案 @@ -40,18 +38,14 @@ Status: implemented **提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 -**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 16 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 +**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 **在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。 -**让 Windows 豁免门禁与插桩分区重叠。** 不予采用,因为完整语料库检查器在独立运行时很快,却会在分区争用下成倍变慢。覆盖率后的阶段把可用 worker 用于豁免检查与观测性检查,不改变任何一项判定。 - ## 后果 每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。 Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。 -Windows 在同一个 job 内使用两个资源阶段:十六个隔离的覆盖率进程先产出合并阈值判定,随后四 worker 的豁免门禁与轻量观测性检查并排运行。 - 未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。 diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml deleted file mode 100644 index 8bf3d97c55..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md -2026-08-22-native-windows-blocks-pull-request-aggregate.md: ddec9536cbb350d3792ae547150b175ef21f1b9e -2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md: 94fa8b3836c15b9c977c39c7539cbb1be5fc882b diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md deleted file mode 100644 index ddec9536cb..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Native Windows blocks the pull-request aggregate - -Status: implemented - -English | [中文](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md) - -## Problem - -Wine reaches blocking win32 toolchain paths quickly, but it cannot prove behavior that depends on the NT kernel, NTFS, PowerShell, Windows process control, or native addons. An `all checks passed` result that can succeed while the complete native job is pending or failed does not enforce the repository's supported Windows behavior. - -The native job runs the complete supported-source coverage denominator and its owning Windows acceptance inventory. Its optimized 16-core hosted run completes within the five-minute target, making that higher-fidelity result short enough for the required pull-request path. - -## Decision - -The `all-checks-passed` job in [ci.yml](../../../../.github/workflows/ci.yml) lists both `windows` and `windows-native` in `needs`. Its existing `if: always()` verdict treats a failed, cancelled, or skipped native job like any other unsuccessful dependency, so `all checks passed` cannot succeed until the real-Windows job succeeds. - -Branch protection continues to require the single stable `all checks passed` context rather than adding the native job name as another protected context. The [dual Windows topology](2026-08-08-native-windows-pull-request-ci.md) owns each job's host, failover selector, and inventory; this note owns their blocking relationship. The aggregate bookkeeping job follows the Linux failover selector for its own runner while `needs` independently waits for the pool selected by `DSH_CI_FAILOVER_WINDOWS`. - -## Alternatives considered - -**Keep native Windows informational.** This preserves the shortest aggregate path, but permits a merge while the highest-fidelity supported Windows verdict is pending or red. - -**Require `windows node 24 / native complete` directly in branch protection.** This duplicates workflow topology in repository settings and makes a job-name change a control-plane migration. The aggregate already provides one stable required context and fails closed over unsuccessful dependencies. - -**Remove Wine from the aggregate.** Native Windows provides higher fidelity, but Wine still returns a faster win32 build and production-site signal, preserves the compatibility topology, and gives maintainers earlier failure evidence while the native inventory runs. - -## Consequences - -Every merge waits for native Windows runner capacity and for the complete native job to finish. A failure, cancellation, or skip in that job makes `all checks passed` fail; a passing Wine job alone is insufficient. - -The workflow remains one pull-request Action with one native Windows job, unchanged test coverage, and unchanged gate semantics inside that job. The required aggregate gains the native job's measured duration without adding a separately managed branch-protection context. diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md deleted file mode 100644 index 94fa8b3836..0000000000 --- a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 原生 Windows 阻断拉取请求聚合流程 - -Status: implemented - -[English](2026-08-22-native-windows-blocks-pull-request-aggregate.md) | 中文 - -## 问题 - -Wine 能快速触达阻断性 win32 工具链路径,但无法证明依赖 NT 内核、NTFS、PowerShell、Windows 进程控制或原生插件的行为。如果 `all checks passed` 能在完整原生作业仍处于待处理或失败状态时成功,它就没有强制验证仓库所支持的 Windows 行为。 - -原生作业会运行完整的受支持源码覆盖率分母及其所属 Windows 验收清单。优化后的 16 核托管运行能在五分钟目标内完成,因此这项保真度更高的结果足够短,可以进入必需的拉取请求路径。 - -## 决策 - -[ci.yml](../../../../.github/workflows/ci.yml) 中的 `all-checks-passed` 作业会在 `needs` 中同时列出 `windows` 与 `windows-native`。其现有的 `if: always()` 判定会像处理其他未成功依赖项一样处理失败、取消或跳过的原生作业,因此真实 Windows 作业成功前,`all checks passed` 无法成功。 - -分支保护继续要求单一且稳定的 `all checks passed` 检查,而不把原生作业名称添加为另一个受保护检查。[Windows 双通道拓扑](2026-08-08-native-windows-pull-request-ci.zh.md)负责每个作业的宿主、故障转移选择器与清单;本文负责二者的阻断关系。聚合记账作业为自身运行器采用 Linux 故障转移选择器,而 `needs` 会独立等待 `DSH_CI_FAILOVER_WINDOWS` 所选池中的作业。 - -## 曾考虑的替代方案 - -**让原生 Windows 只提供信息。** 这会保留最短的聚合路径,但也允许在保真度最高的受支持 Windows 判定仍处于待处理或红灯状态时合并。 - -**在分支保护中直接要求 `windows node 24 / native complete`。** 这会在仓库设置中复制工作流拓扑,并使作业名称变更成为控制面迁移。现有聚合流程已经提供一个稳定的必需检查,并会对未成功的依赖项快速失败。 - -**从聚合流程移除 Wine。** 原生 Windows 的保真度更高,但 Wine 仍能更快返回 win32 构建与生产网站信号、保留兼容性拓扑,并在原生清单运行期间更早地为维护者提供失败证据。 - -## 后果 - -每次合并都会等待原生 Windows 运行器容量与完整原生作业结束。该作业失败、取消或跳过都会使 `all checks passed` 失败;仅 Wine 作业通过并不足够。 - -工作流仍然是单个拉取请求 Action,并保留一个原生 Windows 作业、不变的测试覆盖率以及该作业内不变的门禁语义。必需聚合流程会增加原生作业的实测时长,但无需新增单独管理的分支保护检查。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f02c2919f..f9d4cac33a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -307,10 +307,10 @@ jobs: secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # The pull-request Windows signals cover complementary hosts. The two fast - # win32 toolchain surfaces (workspace build, production site) execute with - # real, checksum-verified Windows Node under Wine on standard hosted Linux. - # The windows-native job below keeps the complete native-kernel inventory — + # The required pull-request Windows signal: the two blocking win32 surfaces + # (workspace build, production site) execute with real, checksum-verified + # Windows Node under Wine on standard hosted Linux. The independent + # windows-native job below keeps the complete native-kernel inventory — # including the observational portability gates this lane does not run — # on real Windows. This job only provisions runner state (caches, # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same @@ -397,12 +397,13 @@ jobs: if: always() run: wineserver -k 2>/dev/null || true - # Every pull request also gets a real Windows-kernel signal. Its unmasked - # conclusion is a dependency of all-checks-passed, so failure, cancellation, - # or omission blocks the required verdict. Under normal operation it runs on - # the hosted larger runner. DSH_CI_FAILOVER_WINDOWS=selfhosted retargets it - # onto the in-house self-hosted Windows pool. Dependabot PRs are excluded - # from the self-hosted pool and stay queued for the hosted runner — see the failover + # Every pull request also gets a real Windows-kernel signal. This job keeps + # its own unmasked conclusion but is deliberately absent from + # all-checks-passed.needs, so it never delays or changes that required + # verdict. Under normal operation it runs on the hosted larger runner; under + # Windows failover (DSH_CI_FAILOVER_WINDOWS=selfhosted) it retargets onto the + # in-house self-hosted Windows pool. Dependabot PRs are excluded from the + # self-hosted pool and stay queued for the hosted runner — see the failover # runbook. This Windows switch is independent of the Linux # DSH_CI_FAILOVER_LINUX variable that retargets the three required Linux jobs # and the all-checks-passed verdict above. @@ -416,16 +417,12 @@ jobs: name: windows node 24 / native complete timeout-minutes: 120 env: - # Partitioned coverage finishes before the heavy uninstrumented gate; - # the latter can use four workers without competing with sixteen shards. - DSH_COVERAGE_MAX_WORKERS: '12' - DSH_COVERAGE_PARTITIONS: '16' + DSH_COVERAGE_MAX_WORKERS: '6' + DSH_COVERAGE_PARTITIONS: '8' # Instrumented process and polling fixtures can exceed Vitest's defaults # under the complete lane's concurrent gate load. DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' - # After the threshold merge, the heavy gate overlaps lightweight - # observational checks within this post-coverage worker budget. - DSH_GATE_CONCURRENCY: '8' + DSH_GATE_CONCURRENCY: '4' DSH_PUBLINT_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 @@ -461,10 +458,10 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`, including both the Wine `windows` job and the real-kernel - # `windows-native` job. (`needs` cannot reach across workflow files; the - # master-only jobs in ci-master.yml are intentionally not part of this PR - # verdict.) + # `needs`. The required Wine job is listed as `windows`; `windows-native` is + # deliberately absent so its independent result never delays or changes this + # verdict. (`needs` cannot reach across workflow files; the master-only jobs in + # ci-master.yml are intentionally not part of this PR verdict.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including @@ -475,15 +472,14 @@ jobs: # provisioning — and under Linux failover it follows the same selector as # the worker jobs it aggregates, so a standard-hosted outage cannot strand # the branch-protection verdict either. It retargets with the Linux switch - # (DSH_CI_FAILOVER_LINUX), not the Windows one, because this bookkeeping job - # itself runs on Linux; the native dependency resolves its Windows pool - # independently. + # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the + # required Linux workers and runs on the vm-backup pool. runs-on: >- ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows, windows-native] + needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows] if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts index 8ed031ebc3..8f1d745ea9 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts @@ -11,107 +11,23 @@ * exemptions as stale. The gate's own note applies to itself: a gate whose * verdict depends on how it was launched is not a gate. * - * Eight Node-loader processes divide the discovered files, and the union check - * proves that each bundle appears once. The two test-support bundles and the - * ACL/win32-process pair stay in one ordered shard because their pinned loader - * exemptions depend on the same preceding module state as the unsharded - * checker. - * * The corpus is the build output, so this skips on a tree that has none. */ -import { spawn } from 'node:child_process' -import { globSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { expect, test } from 'vitest' const runner = fileURLToPath(new URL('./transform-corpus-check.ts', import.meta.url)) -const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) -const corpusShards = 8 -const shardAffinity = new Set([ - // client-runtime needs acp-snapshot to establish Vitest's internal state. - 'packages/test-support/acp-snapshot/lib/index.js', - 'packages/test-support/client-runtime/lib/index.js', - // win32-process observes Koffi's duplicate type names after the ACL bundle. - 'packages/sandbox/sandbox-windows-acl/lib/index.js', - 'packages/subprocess/win32-process/lib/index.js', -]) -interface CorpusResult { - readonly output: string - readonly status: number | null - readonly error?: string -} - -/** @returns Built bundle paths in the same stable order as the checker. */ -function discoverBuiltBundles(): string[] { - return [ - ...globSync('packages/*/*/lib/index.js', { cwd: repositoryRoot }), - ...globSync('vendor/*/lib/index.js', { cwd: repositoryRoot }), - ].map(path => path.replaceAll('\\', '/')).sort() -} - -/** @returns Non-empty shards with every bundle assigned once and loader affinity preserved. */ -function partitionBundles(files: readonly string[], count: number): string[][] { - const partitions = Array.from({ length: count }, () => [] as string[]) - files.forEach((file, index) => { - const assigned = shardAffinity.has(file) ? 0 : index % count - partitions[assigned]?.push(file) - }) - return partitions.filter(partition => partition.length > 0) -} - -/** @returns One isolated Node-loader corpus shard. */ -function runCorpusShard(files: readonly string[]): Promise { - return new Promise((resolveResult) => { - let output = '' - let spawnError: string | undefined - const child = spawn(process.execPath, ['--import', 'tsx/esm', runner, ...files], { - cwd: repositoryRoot, - stdio: ['ignore', 'pipe', 'pipe'], - }) - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { output += chunk }) - child.stderr.on('data', (chunk: string) => { output += chunk }) - child.once('error', (reason) => { spawnError = reason.message }) - child.once('close', (status) => { - resolveResult({ - output, - status, - ...spawnError === undefined ? {} : { error: spawnError }, - }) - }) - }) -} - -test('partitions every bundle once while retaining loader-state affinity', () => { - const files = [ - 'packages/example/first/lib/index.js', - ...shardAffinity, - 'packages/example/last/lib/index.js', - ] - const shards = partitionBundles(files, corpusShards) - - expect(shards.every(shard => shard.length > 0)).toBe(true) - expect(shards.flat().sort()).toEqual([...files].sort()) - expect(shards[0]?.filter(file => shardAffinity.has(file))).toEqual(files.filter(file => shardAffinity.has(file))) -}) - -test('every built bundle transforms to the export shape Node loads', async (context) => { - const files = discoverBuiltBundles() - if (files.length === 0) { +test('every built bundle transforms to the export shape Node loads', (context) => { + const finished = spawnSync(process.execPath, ['--import', 'tsx/esm', runner], { encoding: 'utf8' }) + const output = `${finished.stdout}${finished.stderr}` + if (output.includes('no built bundles found')) { context.skip('the workspace has no build output to sweep') return } - const shards = partitionBundles(files, Math.min(corpusShards, files.length)) - expect(shards.flat().sort()).toEqual(files) - const finished = await Promise.all(shards.map(runCorpusShard)) - const output = finished.map((result, index) => `shard ${String(index + 1)}/${String(shards.length)}:\n${result.output}`).join('\n') // The runner prefixes every finding with '- ', so a failure reads as the // findings themselves rather than as a diff of its whole report. expect(output.split('\n').filter(line => line.startsWith('- ')).join('\n')).toBe('') - for (const result of finished) { - expect(result.error, output).toBeUndefined() - expect(result.status, output).toBe(0) - } + expect(finished.status, output).toBe(0) }, 900_000) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 116bb84e3c..56334523aa 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -36,7 +36,7 @@ describe('CI workflow', () => { } }) - it('keeps required Wine and native Windows jobs with failover, plus a master-only standby', () => { + it('keeps a required Wine Windows job, a non-blocking native Windows job with failover, and a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml') if (!isRecord(workflow.jobs) @@ -73,7 +73,7 @@ describe('CI workflow', () => { expect(windows.if).toBe("github.event_name == 'pull_request'") expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true) - // windows-native: blocking native job with failover, runs windows-complete. + // windows-native: non-blocking native job with failover, runs windows-complete. // Its pool is resolved by the Windows-specific switch. expect(typeof windowsNative['runs-on']).toBe('string') expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS') @@ -84,10 +84,7 @@ describe('CI workflow', () => { expect(windowsNative.name).toBe('windows node 24 / native complete') expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '12', - DSH_COVERAGE_PARTITIONS: '16', DSH_COVERAGE_TEST_TIMEOUT_MS: '30000', - DSH_GATE_CONCURRENCY: '8', }) const nativeSteps = windowsNative.steps as unknown[] const nativeCommandSteps = nativeSteps.filter((step): step is Record & { run: string } => ( @@ -104,9 +101,9 @@ describe('CI workflow', () => { expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') - // Aggregate: both complementary Windows jobs are required. + // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') - expect(aggregate.needs).toContain('windows-native') + expect(aggregate.needs).not.toContain('windows-native') expect(aggregate.needs).not.toContain('serial-windows') // Linux failover is a separate switch: the three required Linux workers diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index 2e68b53b60..eff6ca2b13 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -39,10 +39,4 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' }, { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' }, { filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' }, - // The real corpus transform runs package src only in a spawned Node process, - // outside the parent Vitest worker's v8 coverage session. - { - filter: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - exclude: 'packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts', - }, ] diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index c1f39f8cc8..def26f0a78 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -1,8 +1,8 @@ import { spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative } from 'node:path' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript' import { describe, expect, it } from 'vitest' @@ -39,26 +39,6 @@ function normalizedOutput(result: ReturnType): string { return `${result.stdout}${result.stderr}`.replaceAll('\\', '/') } -/** @returns A transient filename excluded from concurrent repository-wide glob discovery. */ -function hiddenProbeName(prefix: string, suffix: string, extension = '.ts'): string { - return `.${prefix}-${suffix}${extension}` -} - -/** - * Publish a complete probe so concurrent repository scans never read a partial write. - * @param path - Final probe path that the owning project must discover. - * @param source - Complete TypeScript source to publish. - */ -async function publishProbe(path: string, source: string): Promise { - const staging = join(dirname(path), `.${basename(path)}.staging`) - try { - await writeFile(staging, source) - await rename(staging, path) - } finally { - await rm(staging, { force: true }) - } -} - async function writeContractConfig(suffix: string): Promise { const path = join(repositoryRoot, `.oxlintrc.contract-${suffix}.json`) await writeFile(path, JSON.stringify({ extends: ['./.oxlintrc.json'], ignorePatterns: [] })) @@ -79,8 +59,7 @@ describe('Oxlint executable contract', () => { ['example', 'examples/headless-agent/tests', 'tsconfig.host.json'], ['website', 'website', 'tsconfig.host.json'], ] as const - const source = `/** Produce a settled promise for type-aware linting. */ -export function probePromise(): Promise { + const source = `export function probePromise(): Promise { return Promise.resolve() } @@ -91,7 +70,7 @@ probePromise() const paths: Array = [] for (const [label, parent, tsconfig, extension = '.ts'] of probes) { const path = join(repositoryRoot, parent, `oxlint-contract-${suffix}${extension}`) - await publishProbe(path, source) + await writeFile(path, source) paths.push([label, relative(repositoryRoot, path), tsconfig]) } const clientScript = 'scripts/client-bundle-purity.spec.ts' @@ -109,7 +88,7 @@ probePromise() expect(result.error).toBeUndefined() expect(result.status, output).toBe(1) for (const [label, path, tsconfig] of paths) { - expect(output, label).toContain(`${path.replaceAll('\\', '/')}:6:1: Promises must be awaited`) + expect(output, label).toContain(`${path.replaceAll('\\', '/')}:5:1: Promises must be awaited`) expect(output, `${label} project`).toContain( `Got tsconfig for file ${join(repositoryRoot, path).replaceAll('\\', '/')}: ${join(repositoryRoot, tsconfig).replaceAll('\\', '/')}`, ) @@ -131,7 +110,7 @@ probePromise() it('runs JavaScript compatibility and nursery rules', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) - const path = join(repositoryRoot, 'scripts', hiddenProbeName('oxlint-contract', suffix)) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) const source = `export function firstProbe(): number { const first = 1 const second = 2 @@ -251,7 +230,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('reports an unused suppression', async () => { const suffix = randomUUID() const configPath = await writeContractConfig(suffix) - const path = join(repositoryRoot, 'scripts', hiddenProbeName('oxlint-contract', suffix)) + const path = join(repositoryRoot, 'scripts', `oxlint-contract-${suffix}.ts`) try { await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') @@ -301,7 +280,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + expect(stagedConfig.ignorePatterns).not.toContain('packages/typert/generator/tests/fixtures/type-model/**') const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, 'export const value={answer:1};\n') const lint = runOxlint([ @@ -324,7 +303,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('preserves successful fix output channels', async () => { const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') @@ -348,7 +327,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + it('prints only the final diagnostics when a fix retry still fails', async () => { const suffix = randomUUID() - const path = join(repositoryRoot, 'scripts', hiddenProbeName('staged-lint-probe', suffix)) + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { await writeFile(path, `export const longProbe = ${'1 + '.repeat(80)}1\n`) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index e7188f7709..1535b27ba3 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -169,13 +169,14 @@ describe('gate graph validation', () => { expect(byId.get('coverage')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true) expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build') - expect(byId.get('coverage-exempt-heavy')?.after).toContain('coverage') expect(observational).not.toHaveLength(0) for (const gate of observational) { const completeGate = byId.get(gate.id) expect(completeGate?.allowFailure).toBe(true) - expect(completeGate?.after).toContain('coverage') - expect(completeGate?.after).not.toContain('coverage-exempt-heavy') + expect(completeGate?.after).toEqual(expect.arrayContaining([ + 'coverage', + 'coverage-exempt-heavy', + ])) expect(completeGate?.needs).toEqual(gate.needs) } }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e24e662153..45af4dc99e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -472,12 +472,9 @@ function ciWindowsBlockingGates(): Gate[] { function ciWindowsCompleteGates(): Gate[] { const coverage = coverageGates().map(gate => gate.id === 'coverage-exempt-heavy' - ? { - ...gate, - needs: [...new Set(['build', ...(gate.needs ?? [])])], - after: [...new Set(['coverage', ...(gate.after ?? [])])], - } + ? { ...gate, needs: [...new Set(['build', ...(gate.needs ?? [])])] } : gate) + const coverageAfter = coverage.map(gate => gate.id) const observational = ciWindowsObservationalGates() // The required production site replaces the observational MPA build; both // VitePress modes write the same output directory and cannot overlap. @@ -485,7 +482,7 @@ function ciWindowsCompleteGates(): Gate[] { .map(gate => ({ ...gate, allowFailure: true, - after: [...new Set(['coverage', ...(gate.after ?? [])])], + after: [...new Set([...coverageAfter, ...(gate.after ?? [])])], })) return [ ciBuildGate(), From 558f08780cceaeea0f1e8429120a50c46a9d35d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 14:51:03 +0800 Subject: [PATCH 06/42] =?UTF-8?q?refactor(attachment):=20=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E5=AE=BF=E4=B8=BB=E4=BD=8D=E7=BD=AE=E4=B8=8E=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=AE=BF=E9=97=AE=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 4 +- ...08-20-unified-image-request-pipeline.zh.md | 4 +- ...08-21-model-readable-image-paths.i18n.yaml | 4 +- .../2026-08-21-model-readable-image-paths.md | 26 +++++--- ...026-08-21-model-readable-image-paths.zh.md | 26 +++++--- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 20 ++----- docs/subsystems/attachment.zh.md | 20 ++----- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 12 ++++ docs/subsystems/llm-streaming.zh.md | 12 ++++ .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 6 +- .../attachment/attachment-local/README.zh.md | 6 +- .../attachment/attachment-local/package.json | 3 - .../attachment/attachment-local/src/index.ts | 11 +--- .../attachment-local/tests/index.spec.ts | 24 ++++---- .../attachment/attachment-local/tsconfig.json | 1 - .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 6 +- packages/attachment/attachment/README.zh.md | 6 +- packages/attachment/attachment/src/index.ts | 8 +-- packages/attachment/attachment/src/types.ts | 8 --- .../attachment/attachment/tests/index.spec.ts | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 12 ++-- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/README.zh.md | 6 +- packages/llm/llm-deepseek/package.json | 2 + packages/llm/llm-deepseek/src/adapter.ts | 11 +++- packages/llm/llm-deepseek/src/index.ts | 8 ++- packages/llm/llm-deepseek/src/serialize.ts | 18 +++--- .../llm/llm-deepseek/tests/adapter.spec.ts | 2 +- .../llm/llm-deepseek/tests/serialize.spec.ts | 9 +-- packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 6 +- packages/llm/llm-pi-ai/README.zh.md | 6 +- packages/llm/llm-pi-ai/package.json | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 18 ++++-- packages/llm/llm-pi-ai/src/context.ts | 60 +++++++++++-------- packages/llm/llm-pi-ai/src/index.ts | 8 ++- packages/llm/llm-pi-ai/tests/context.spec.ts | 46 +++++++++----- packages/llm/llm-pi-ai/tests/convert.spec.ts | 10 +++- packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/README.zh.md | 4 +- packages/llm/llm/src/content.ts | 47 +++++++++++++-- packages/llm/llm/tests/content.spec.ts | 32 +++++++++- pnpm-lock.yaml | 12 ++-- scripts/gen-cordis-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 10 ++-- 57 files changed, 366 insertions(+), 236 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index cca3335220..66ba6d1139 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 7af0bea0acb117844b258a0333e97e97facf493c -2026-08-20-unified-image-request-pipeline.zh.md: ecdb71548190ba22573a5e05c3b3fde76c28d511 +2026-08-20-unified-image-request-pipeline.md: 85296f1d8bd7d6ee458f8bc4230e52f1d3128bff +2026-08-20-unified-image-request-pipeline.zh.md: bcbc0110001e9f974e57a456140e9ecdf4eb888c diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 7af0bea0ac..85296f1d8b 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -26,11 +26,11 @@ Batch admission prepares and verifies every normalized attachment once before pu The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. -Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a deterministic per-image placeholder that retains its identity and current provider access facts, including nested tool-result images, while append-only session history keeps the original references. +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references. ### Stable handles -Every retained request image is preceded by its display name or complete attachment id, actual request dimensions, and current provider access facts. The local provider supplies the absolute read-only normalized-object path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is derived from the logged reference and current provider root at request time; it does not enter the durable reference or session log. +Every retained request image is preceded by its display name or complete attachment id and actual request dimensions. The attachment provider can supply its host object location; the LLM consumer combines it with the current filesystem mapping before adding an absolute read-only path, normalized dimensions, and media type. The descriptor states that normalization or request projection may have resized or re-encoded the upload, so the model cannot infer original upload properties from either representation. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. The path is resolved independently from the deterministic request version and does not enter its `variantId`, the durable reference, or the session log. ### DeepSeek Files lifecycle diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index ecdb715481..bcbc011000 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -26,11 +26,11 @@ Status: implemented `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 -请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成确定性的逐图占位文本,保留自己的身份和当前提供方访问事实,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 ### 稳定句柄 -每张保留请求图片前都有显示名称或完整附件 ID、实际请求尺寸,以及当前提供方访问事实。本地提供方会给出规范化对象的绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径在请求时根据已记录引用和当前提供方根目录派生,不进入持久引用或会话日志。 +每张保留请求图片前都有显示名称或完整附件 ID,以及实际请求尺寸。附件提供方可以给出宿主对象位置;LLM 消费方将其与当前文件系统映射组合后,再加入绝对只读路径、规范化尺寸和媒体类型。描述会说明规范化或请求投影可能缩小或重新编码上传图片,因此模型不能从任一版本推断上传图片原本的属性。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。路径独立于确定性请求版本解析,不进入其 `variantId`、持久引用或会话日志。 ### DeepSeek Files 生命周期 diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml index 314dabaa74..b9516c493a 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.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-21-model-readable-image-paths.md -2026-08-21-model-readable-image-paths.md: 729e6347e8623694e1cc4f4779bf71447006cb08 -2026-08-21-model-readable-image-paths.zh.md: d67817cf699afb6b094524388dd6d5a48a653e90 +2026-08-21-model-readable-image-paths.md: 728d25ee84836a07805f9d8728e47e9319e0863d +2026-08-21-model-readable-image-paths.zh.md: 2596dec5a21a8908d5875ab031bed6655b2305ca diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md index 729e6347e8..728d25ee84 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.md @@ -1,4 +1,4 @@ -# Agent Note: Provider-resolved image paths in model requests +# Agent Note: Execution-world image paths in model requests Status: implemented @@ -10,21 +10,29 @@ An uploaded image becomes an opaque durable `ImageAttachmentRef`. Image-capable ## Decision -`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageAccess(ref)` resolves optional access facts from the current provider. The local provider derives an absolute object path from the resolved `DSH_HOME`, attachment storage version, and validated digest, then asks the mounted `ctx.fs` to map that host file into its execution world. `FileSystem.processPathFromHostPath(hostPath)` returns no mapping by default; the host-backed provider returns the absolute process path, while E2B and other remote providers return no access facts. A request version carries available facts transiently for serialization. +`ImageAttachmentRef` remains portable session data and contains no host path. `AttachmentStore.imageHostPath(ref)` exposes only the attachment provider's optional host object location. The local provider derives that absolute path from the resolved `DSH_HOME`, attachment storage version, and validated digest; it does not inspect the model-tool execution world. -The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the provider-resolved normalized path when available. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. +Each LLM provider plugin supplies the bridge at request assembly. It combines the attachment store with the current `ctx.fs.processPathFromHostPath(hostPath)` mapping and passes the resulting `ImageAttachmentAccess` independently into retained-image serialization and offload placeholders. The filesystem service is authoritative for this question because the mounted filesystem and subprocess providers share one execution world. A separate subprocess-presence check would not prove path reachability. Host-backed filesystems return a process path; E2B and other remote backends without a shared mount return no mapping. -Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and current access facts without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged. +`RequestImageAttachment` remains a deterministic version selected by the attachment and route policy. It contains `variantId`, encoded bytes, dimensions, and encoding metadata, but no access path. Execution-world access may change with the host or mounted providers and does not participate in `variantId`. -Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Access resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. +The shared LLM image descriptor names the display name or full attachment id, the exact request-preview dimensions, and the execution-world path when the bridge resolves one. Local access text includes normalized dimensions and media type, identifies the object as read-only, directs the model to copy it to a writable path with the matching extension before editing, and states that normalization or request projection may have resized or re-encoded the upload. DeepSeek Files and pi-ai inline requests use the same descriptor. -Absolute paths stay out of session events. Model-visible path text is reconstructed from the logged attachment reference and the providers mounted for the current process. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications. +Request-size offload requires a per-image placeholder function; the previous shared placeholder constant and its byte-bound wrapper had no remaining production caller and are removed. DeepSeek and pi-ai replace each omitted occurrence with its own attachment identity and access resolved for that request without reading or transforming the omitted object. Offload selection, byte accounting, and quantized prefix behavior remain unchanged. + +Descriptor identity comes from each occurrence's own durable reference, not from the prepared request version: versions are deduplicated per attachment id, so two uploads of the same content under different names share one version while each occurrence keeps its own display name. Host-location resolution validates the logged attachment id; a malformed reference in durable history fails the request at assembly, the earliest point that resolves it. + +Absolute paths stay out of session events and request-image versions. Model-visible path text is reconstructed from the logged attachment reference, the current attachment provider, and the filesystem mounted for the current execution world. Restoring the same session with a different `DSH_HOME` produces the path valid on that host; restoring it with a remote execution world that has no shared mount produces no path. Published attachment objects use owner-read-only mode, including deduplicated objects, and model instructions require a writable copy for modifications. ## Alternatives considered **Persist the absolute path in `ImageAttachmentRef`.** A durable host path becomes stale after moving a session, changing `DSH_HOME`, or mounting another provider. Resolving it at request time preserves portable history. -**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns this fact. +**Teach each LLM adapter the `~/.dsh` layout.** Explicit `dshHome` and `$DSH_HOME` can select another root, and non-local providers may expose no path. The attachment provider owns the object location; the shared LLM helper owns the composition with an execution-world mapper. + +**Let the attachment provider inspect `ctx.fs`.** The attachment provider knows where its object lives but does not own the model tools' execution environment. Combining both services in the LLM consumer also avoids a package reference cycle between attachment, filesystem, and LLM definitions. + +**Store `ImageAttachmentAccess` in `RequestImageAttachment`.** Request versions are deterministic cache and upload values. A path that changes with the host or execution environment must not affect their identity or appear to be part of their reproducible data. **Infer path sharing from the filesystem provider's package or class name.** Provider identity does not establish that a host file is mounted into its execution world. The filesystem provider instead answers the exact mapping question and can support an explicit shared mount without changing the attachment provider. @@ -32,8 +40,8 @@ Absolute paths stay out of session events. Model-visible path text is reconstruc ## Verification -Package tests cover provider access defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, request-version access propagation, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. +Package tests cover provider host-location defaults, host-backed path mapping, absence without a mapped filesystem, digest-to-path resolution, owner-read-only publication and deduplication, access passed independently from request versions, retained-image descriptions, per-image nested offload placeholders, source-property warnings, and matching extensions. A keyless assembled ACP snapshot checks the exact local object path in both a retained DeepSeek Files image handle and an offloaded image placeholder. ## Consequences -When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths change when the provider root changes, while deterministic image bytes and session references remain unchanged. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it. +When the execution world maps the attachment object, the selected model provider receives its path. This disclosure lets the model operate on the stored image and is limited to normalized attachment objects already in that request's authorized history. Descriptor text adds tokens for every retained or offloaded image. Paths can change while deterministic image bytes, `variantId`, and session references remain unchanged; the changed historical text can prevent KV-cache reuse from the first affected image even when no image is offloaded. Remote execution worlds without a shared mount receive the existing no-path recovery text. A missing local object still fails when a model tool attempts to read it. diff --git a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md index d67817cf69..2596dec5a2 100644 --- a/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md +++ b/.agents/notes/implemented/feature/2026-08-21-model-readable-image-paths.zh.md @@ -1,4 +1,4 @@ -# Agent Note:在模型请求中提供由附件提供方解析的图片路径 +# Agent Note:在模型请求中提供执行环境图片路径 状态:已实现 @@ -10,21 +10,29 @@ ## 决策 -`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageAccess(ref)` 从当前提供方解析可选访问事实。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生对象的绝对路径,再要求当前 `ctx.fs` 把这个宿主文件映射到其执行世界。`FileSystem.processPathFromHostPath(hostPath)` 默认不返回映射。宿主后端返回绝对进程路径,E2B 和其他远程后端不返回访问事实。请求版本只在序列化期间携带当前可用的事实。 +`ImageAttachmentRef` 继续作为可移植的会话数据,不包含宿主路径。`AttachmentStore.imageHostPath(ref)` 只公开附件提供方可选的宿主对象位置。本地提供方根据已解析的 `DSH_HOME`、附件存储版本和经过校验的摘要派生该绝对路径,不检查模型工具的执行环境。 -共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及当前可用的提供方规范化路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 +每个 LLM 提供方插件在组装请求时提供桥接逻辑。它把附件存储与当前 `ctx.fs.processPathFromHostPath(hostPath)` 映射组合起来,再将得到的 `ImageAttachmentAccess` 单独传给保留图片序列化和 offload 占位内容。当前文件系统服务可以回答这个问题,因为挂载的文件系统和子进程提供方共享同一执行环境。另行检查子进程服务是否存在并不能证明路径可达。宿主文件系统返回进程路径;E2B 和其他没有共享挂载的远程后端不返回映射。 -请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和当前访问事实,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。 +`RequestImageAttachment` 仍是由附件和路由策略确定的请求版本。它包含 `variantId`、编码字节、尺寸和编码元数据,不包含访问路径。执行环境访问方式可能随宿主或挂载的提供方变化,不参与 `variantId`。 -描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。访问解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 +共用 LLM 图片描述会写明显示名称或完整附件 ID、确切请求预览尺寸,以及桥接逻辑能够解析出的执行环境路径。本地访问文本还包含规范化尺寸和媒体类型,说明对象只供读取,要求模型在编辑前按匹配扩展名复制到可写路径,并指出规范化或请求投影可能缩小或重新编码上传图片。DeepSeek Files 与 pi-ai 内联请求使用同一描述。 -绝对路径不会进入会话事件。模型可见路径根据已记录附件引用和当前进程挂载的提供方重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行世界中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。 +请求大小 offload 要求提供逐图占位文本函数;原先共享的占位常量及其字节上限包装函数已没有生产调用方,因此删除。DeepSeek 和 pi-ai 会把每个省略位置替换为该图片自己的附件身份和本次请求解析出的访问方式,无需读取或转换省略对象。Offload 的选择、字节计量和按固定步长变化的前缀行为保持不变。 + +描述文本的身份来自每个出现位置自己的持久引用,而不是准备好的请求版本:请求版本按附件 ID 去重,同一内容以不同文件名上传两次会共享一个版本,但每个出现位置保留自己的显示名称。宿主位置解析会校验已记录的附件 ID;持久历史中的畸形引用会在请求组装时失败,这是能解析它的最早时点。 + +绝对路径不会进入会话事件或请求图片版本。模型可见路径根据已记录附件引用、当前附件提供方和当前执行环境挂载的文件系统重建。同一会话在不同 `DSH_HOME` 下恢复时会得到该宿主上的有效路径;在没有共享挂载的远程执行环境中恢复时不会得到路径。发布的附件对象采用仅所有者可读的权限,去重对象也执行该权限。模型指令要求先复制到可写位置再修改。 ## 考虑过的替代方案 **把绝对路径持久保存到 `ImageAttachmentRef`。** 移动会话、更改 `DSH_HOME` 或挂载其他提供方后,持久宿主路径会失效。请求时解析可以保持历史可移植。 -**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。该事实属于附件提供方。 +**让每个 LLM 适配器了解 `~/.dsh` 布局。** 显式 `dshHome` 和 `$DSH_HOME` 可以选择其他根目录,非本地提供方也可能没有路径。附件提供方拥有对象位置,共用 LLM 帮助函数负责与执行环境映射组合。 + +**让附件提供方检查 `ctx.fs`。** 附件提供方知道对象在哪里,但不拥有模型工具的执行环境。在 LLM 消费方组合两个服务,也避免了附件、文件系统和 LLM 定义包之间的项目引用循环。 + +**把 `ImageAttachmentAccess` 放进 `RequestImageAttachment`。** 请求版本是确定性的缓存和上传值。随宿主或执行环境变化的路径不能影响其身份,也不能表现为可重复生成的数据。 **根据文件系统提供方的包名或类名判断是否共享路径。** 提供方身份不能证明宿主文件已挂载到其执行世界。文件系统提供方直接回答具体路径能否映射,也允许未来的显式共享挂载在不修改附件提供方的情况下返回映射。 @@ -32,8 +40,8 @@ ## 验证 -包测试覆盖提供方访问默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、请求版本访问事实传播、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 +包测试覆盖提供方宿主位置默认值、宿主后端路径映射、没有文件系统映射时省略路径、本地摘要到路径的解析、仅所有者可读的发布与去重、独立于请求版本传入访问方式、保留图片描述、逐图嵌套 offload 占位文本、源属性提醒和匹配扩展名。Keyless ACP 组装快照会检查保留的 DeepSeek Files 图片句柄和被 offload 图片占位文本中的确切本地对象路径。 ## 后果 -执行世界能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。提供方根目录变化时路径会变化,确定性图片字节和会话引用保持不变。没有共享挂载的远程执行世界会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。 +执行环境能够映射附件对象时,所选模型提供方会收到该路径。模型可借此操作存储图片,路径只指向该请求已授权历史中的规范化附件对象。每张保留或被 offload 的图片都会增加描述文本 token。路径变化时,确定性图片字节、`variantId` 和会话引用可以保持不变;即使没有图片被 offload,变化后的历史文本也可能使 KV 缓存从首张受影响图片起无法复用。没有共享挂载的远程执行环境会收到原有的无路径恢复文本。本地对象缺失时,模型工具读取它仍会失败。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c0ac258326..6b11f0e6ac 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: 7af4d4e47866736ca5f100d07dbd04f8341ae4a0 -config-catalog.zh.md: 9e1d3739d25b309172f2a9261cb10e8c5775af12 +config-catalog.md: 3b8b44211794a8334fd4cc32171c3fe33994eade +config-catalog.zh.md: e1554020282a454047716a11632e602916929b0b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7af4d4e478..3b8b442117 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) @@ -950,7 +950,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9e1d3739d2..e155402028 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -319,7 +319,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:53`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) @@ -952,7 +952,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:106`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index fc14d236c4..bbb4e4b38f 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: d855a31c4b463098e30652a30e807d648298c952 -attachment.zh.md: 314a6486266fe27d5e5fda17ae1b0c09ee65c5f3 +attachment.md: 7fafae424902632d8190e5d156b72795c31dcbbc +attachment.zh.md: 2eed9515a82fa8bba290a5d07f41e521dd7565d8 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index d855a31c4b..7fafae4249 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -10,7 +10,7 @@ Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachmen ## Identity and verified metadata -`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. Consumers call `imageAccess()` when they need a path resolved by the mounted provider. +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. A consumer may ask the attachment provider for its object location through `imageHostPath()`, then must use the current execution filesystem to decide whether model tools can read that host path. ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,14 +93,6 @@ interface StoredImageAttachment { } ``` -```ts type-equiv -/** Provider-resolved ways for model tools to access one normalized attachment. */ -interface ImageAttachmentAccess { - /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ - readonlyPath: string -} -``` - ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -118,8 +110,6 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef - /** Transient provider access facts; never persisted in the durable reference. */ - access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -135,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageAccess()` resolves current-provider access facts without storing host paths in session data. `readImageRequest()` derives and caches one request version under an exact route pixel and byte budget; new entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -187,12 +177,12 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Resolve provider-specific model-tool access without adding host facts to session history. + * Locate the provider-owned normalized object in the harness host filesystem. * @param ref - durable normalized attachment reference. - * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. * @throws an AttachmentError when the durable reference is invalid. */ -imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined +imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 314a648626..2eed9515a8 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -10,7 +10,7 @@ ## 标识与经过校验的元数据 -`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方需要路径时调用 `imageAccess()`,由当前挂载的提供方负责解析。 +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。消费方可以通过 `imageHostPath()` 询问附件提供方所持对象的位置,然后必须由当前执行文件系统判断模型工具能否读取该宿主路径。 ```ts type-equiv /** Raster image formats accepted by the version-one attachment path. */ @@ -93,14 +93,6 @@ interface StoredImageAttachment { } ``` -```ts type-equiv -/** Provider-resolved ways for model tools to access one normalized attachment. */ -interface ImageAttachmentAccess { - /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ - readonlyPath: string -} -``` - ```ts type-equiv /** Deterministic request-image policy selected by one exact model route. */ interface ImageRequestPolicy { @@ -118,8 +110,6 @@ interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef - /** Transient provider access facts; never persisted in the durable reference. */ - access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType @@ -135,7 +125,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageAccess()` 解析当前提供方的访问信息,无需把宿主路径写入会话数据。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存请求版本;新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -187,12 +177,12 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Resolve provider-specific model-tool access without adding host facts to session history. + * Locate the provider-owned normalized object in the harness host filesystem. * @param ref - durable normalized attachment reference. - * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. * @throws an AttachmentError when the durable reference is invalid. */ -imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined +imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index d3f211267a..173e05729d 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: 4ad7af5673f3894d86af72b04db365fcc0f36608 -llm-streaming.zh.md: ff62bccdac018ea57b58d5edec9b7dae448be76a +llm-streaming.md: bdc830a5d387cde6967575551ec9b0a9b2626f46 +llm-streaming.zh.md: b602336bc06cd88a2634f5259eff117da3dcd986 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4ad7af5673..bdc830a5d3 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -30,6 +30,18 @@ interface ContentBlockMap { The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. + +Source: [`packages/llm/llm/src/content.ts`](../../packages/llm/llm/src/content.ts) + +```ts type-equiv +/** Execution-world path that model tools can use to read one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + Source: [`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) A `Message` is one identified, immutable role/source/content value. Model-produced assistant messages name the provider and model that produced them and carry optional adapter-private replay data in their source: diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ff62bccdac..b602336bc0 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -30,6 +30,18 @@ interface ContentBlockMap { 各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 + +源码:[`packages/llm/llm/src/content.ts`](../../packages/llm/llm/src/content.ts) + +```ts type-equiv +/** Execution-world path that model tools can use to read one normalized attachment. */ +interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} +``` + 源码:[`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) `Message` 是一个带标识且不可变的角色/来源/内容值。模型生成的 assistant 消息会在来源中记录生成它的提供方和模型,以及可选的适配器私有回放数据: diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index dd994eee03..4916c8ad2e 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: c93f32ede4599d8e07536062dd7d44134bea16b0 -README.zh.md: f06c3aeec937e0eb09ea0aa2ef25d41119e54aa8 +README.md: d9b6839c9b02fdfa4a0661ed44ae77e32e9a255e +README.zh.md: 11b4a6239e8b14ecfcc905e40a4cdc5042de5fcf diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index c93f32ede4..d9b6839c9b 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -8,15 +8,15 @@ Admission accepts at most 20 images and 200MiB of encoded source bytes per messa Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. At request assembly, `imageAccess` derives the normalized object's absolute host path and asks the mounted `ctx.fs` to map that file into its execution world. Host-backed filesystems return a read-only process path; remote filesystems without a shared mount return no path. The path is absent from durable history. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. `imageHostPath` derives the normalized object's absolute host path and does not inspect the tool execution world. At request assembly, an LLM consumer asks the mounted filesystem to map that host object into its execution world. A host-backed filesystem returns a process path; a remote filesystem without a shared mount returns no path. The mapped path is absent from durable history and from `RequestImageAttachment`. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience -Indirectly, through request descriptors that give the model each retained or offloaded image's identity, dimensions, media type, available read-only normalized-object path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. +Indirectly, through request descriptors. When the current execution filesystem maps this backend's host object, the model receives each retained or offloaded image's identity, dimensions, media type, read-only mapped path, matching extension for a writable copy, and a warning that normalization may have resized or re-encoded the upload. #### KV Cache effect -Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. +Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns. Execution-world path mapping is resolved separately and can change historical descriptor text without changing those bytes or their `variantId`. ## Known Limitations and Deferred Work diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index f06c3aeec9..11b4a6239e 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -8,15 +8,15 @@ 请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。组装请求时,`imageAccess` 先派生规范化对象的绝对宿主路径,再要求当前 `ctx.fs` 把该文件映射到其执行世界。宿主文件系统返回只读进程路径;没有共享挂载的远程文件系统不返回路径。该路径不进入持久历史。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。`imageHostPath` 派生规范化对象的绝对宿主路径,不检查工具执行环境。组装请求时,LLM 消费方要求当前文件系统把该宿主对象映射到其执行环境。宿主文件系统返回进程路径;没有共享挂载的远程文件系统不返回路径。映射后的路径不进入持久历史,也不进入 `RequestImageAttachment`。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 -该包通过请求描述间接影响模型,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、当前可用的只读规范化对象路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 +该包通过请求描述间接影响模型。当前执行文件系统能够映射本后端的宿主对象时,描述会给出每张保留或被 offload 图片的身份、尺寸、媒体类型、映射后的只读路径、复制到可写位置时使用的匹配扩展名,以及规范化过程可能缩小或重新编码上传图片的提醒。 #### KV 缓存影响 -规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。 +规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节。执行环境路径单独解析;它的映射变化会改变历史描述文本,但不会改变请求字节或 `variantId`。 ## 已知限制与待完成工作 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index cb2472f62a..194112be29 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -23,7 +23,6 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -34,8 +33,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 6a22038154..053fa69fc6 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -5,7 +5,6 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { - ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -13,7 +12,6 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import type {} from '@deepseek-ai/dsh-fs' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter } from './compression-limiter.ts' @@ -209,10 +207,8 @@ export class LocalAttachmentStore extends AttachmentStore { return readImageFile(this.root, ref, signal) } - override imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { - const hostPath = normalizedImagePath(this.root, ref) - const readonlyPath = this.ctx.get('fs')?.processPathFromHostPath(hostPath) - return readonlyPath === undefined ? undefined : { readonlyPath } + override imageHostPath(ref: ImageAttachmentRef): string { + return normalizedImagePath(this.root, ref) } override async readImageRequest( @@ -245,8 +241,7 @@ export class LocalAttachmentStore extends AttachmentStore { policy, sharedSignal, ) - const access = this.imageAccess(ref) - return { ...request, ...(access === undefined ? {} : { access }) } + return request })) operation = shared this.requestInflight.set(key, shared) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index ba18777c75..ef06768d55 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,6 +1,5 @@ import { Context } from '@deepseek-ai/cordis' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -46,8 +45,13 @@ describe('local attachment service', () => { width: 1, height: 1, } - expect(service.imageAccess(ref)).toBeUndefined() - expect(() => service.imageAccess({ ...ref, attachmentId: AttachmentId('invalid') })) + expect(service.imageHostPath(ref)).toBe(join( + service.root, + 'objects', + 'aa', + 'a'.repeat(64), + )) + expect(() => service.imageHostPath({ ...ref, attachmentId: AttachmentId('invalid') })) .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) }) @@ -61,20 +65,16 @@ describe('local attachment service', () => { it('saves and reads through the service boundary', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-')) - const ctx = new Context() - new LocalFileSystem(ctx, { cwd: dshHome, diffBasisMaxBytes: 10 * 1024 * 1024 }) try { - const service = new LocalAttachmentStore(ctx, { dshHome }) + const service = new LocalAttachmentStore(new Context(), { dshHome }) const data = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) - const access = service.imageAccess(ref) - expect(access).toBeDefined() - if (access === undefined) throw new Error('expected a host-path mapping from fs-local') - expect(access.readonlyPath).toBe(join( + const hostPath = service.imageHostPath(ref) + expect(hostPath).toBe(join( dshHome, 'attachments', 'v1', @@ -82,9 +82,9 @@ describe('local attachment service', () => { String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 2), String(ref.attachmentId).slice('sha256:'.length), )) - await expect(readFile(access.readonlyPath)).resolves.toEqual(Buffer.from(data)) + await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) - expect(request.access).toEqual(access) + expect(request).not.toHaveProperty('access') } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json index a5fff7a98a..3ac8b3fcff 100644 --- a/packages/attachment/attachment-local/tsconfig.json +++ b/packages/attachment/attachment-local/tsconfig.json @@ -6,7 +6,6 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../attachment" }, - { "path": "../../fs/fs" }, { "path": "../../util/home-paths" }, { "path": "../../runtime-diagnostics/invariants" } ] diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 1a55f27a2c..a2fb7e3452 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 63c6a41fca3d0d7ea87d9f30e682e7e4e7aa5906 -README.zh.md: 4eb08dd169e1d0f2b20ada050513caaf88a84891 +README.md: 21030a492464aae528d4c06b4b72d4a94c0a2603 +README.zh.md: 0540996f99b3250331e567e174264cf7da8aa474 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 63c6a41fca..21030a4924 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,17 +4,17 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and durably commits a provider-independent normalized image, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, local storage paths, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageAccess` resolves optional current-provider access facts, such as an absolute read-only path, without adding host-specific data to the durable reference. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, prepares every normalized attachment before publishing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and returns its `ImageAttachmentRef`. When normalization reduces the raster, the reference records the orientation-applied input size in `originalDimensions`. `readImage` verifies the normalized attachment against its logged metadata. `readImageRequest` deterministically derives a route-sized request version whose identity covers the attachment id, transform version, pixel and byte budgets, and encoder settings. `imageHostPath` optionally exposes the provider-owned object's absolute host path; it makes no claim that the current model tools can read that path. An LLM consumer combines this location with the mounted filesystem's execution-world mapping when it serializes a request. That current access path remains separate from the request version and its `variantId`. Callers compose ordered batches with `Promise.all(refs.map(...))`; the local implementation still bounds compression through its instance limiter, cache, and singleflight. Callers may cancel reads and projections; implementations preserve cancellation instead of translating it into a storage failure. `admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. A backend that offers model-tool access can also expose its current read-only normalized path; the descriptor states that normalization may have resized or re-encoded the upload. +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference into an exact request version. Request descriptors expose the complete attachment id and actual request dimensions. When the attachment backend exposes a host object and the current execution filesystem maps it, the descriptor also exposes the resulting read-only path; it states that normalization may have resized or re-encoded the upload. #### KV Cache effect -Adding an image changes the provider request and therefore invalidates the affected request suffix. +Adding an image changes the provider request and therefore invalidates the affected request suffix. A changed execution-world path can also change historical descriptor text without changing the deterministic request version. ## Known Limitations and Deferred Work diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 4eb08dd169..0540996f99 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,17 +4,17 @@ 持久附件服务边界。`ctx.attachments` 校验并持久提交提供方无关的规范化图片,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL、本地存储路径或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageAccess` 解析当前提供方可选的访问事实,例如绝对只读路径,同时避免把宿主信息加入持久引用。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整准入策略但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,在发布任何成员前准备全部规范化附件,然后按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并直接返回 `ImageAttachmentRef`。规范化过程缩小图片时,引用会通过 `originalDimensions` 记录应用方向后的输入尺寸。`readImage` 根据已记录的元数据校验规范化附件。`readImageRequest` 确定性派生路由所需的请求版本,其身份覆盖附件 ID、变换策略版本、像素和字节预算及编码参数。`imageHostPath` 可以给出提供方所持对象的绝对宿主路径,但不保证当前模型工具能够读取它。LLM 消费方在序列化请求时将这个位置与当前文件系统提供的执行环境映射组合起来。解析出的访问路径独立于请求版本及其 `variantId`。调用方通过 `Promise.all(refs.map(...))` 组合有序批次,本地实现仍通过实例级限流器、缓存和 singleflight 限制压缩并发。调用方可以取消读取和投影;实现保留取消结果,不把它转换为存储失败。 `admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。支持模型工具访问的后端还可以公开当前规范化附件的只读路径;描述会说明规范化过程可能缩小或重新编码上传图片。 +该包通过角色无关的核心 `ImageBlock`,以及把持久引用解析为确定请求版本的提供方适配器,间接影响模型。请求描述会公开完整附件 ID 和实际请求尺寸。附件后端给出宿主对象且当前执行文件系统能够映射该对象时,描述还会公开映射后的只读路径,并说明规范化过程可能缩小或重新编码上传图片。 #### KV 缓存影响 -添加图片会改变提供方请求,因此会使受影响的请求后缀失效。 +添加图片会改变提供方请求,因此会使受影响的请求后缀失效。即使确定性的请求版本不变,执行环境路径变化也会改变历史描述文本。 ## 已知限制与待完成工作 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index f72b0b2052..ccfc3a3f98 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -4,7 +4,6 @@ import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentError } from './error.ts' import type { ImageAttachmentLimits, - ImageAttachmentAccess, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, @@ -19,7 +18,6 @@ export { admitEncodedImages } from './admission.ts' export type { AttachmentId as AttachmentIdType, EncodedImageAttachment, - ImageAttachmentAccess, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, @@ -110,12 +108,12 @@ export abstract class AttachmentStore extends Service { abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise /** - * Resolve provider-specific model-tool access without adding host facts to session history. + * Locate the provider-owned normalized object in the harness host filesystem. * @param ref - durable normalized attachment reference. - * @returns current-provider access facts, or undefined when this backend exposes no local path. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. * @throws an AttachmentError when the durable reference is invalid. */ - imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined { + imageHostPath(ref: ImageAttachmentRef): string | undefined { void ref return undefined } diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 0b7d382b64..e23a7a7d4c 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -67,12 +67,6 @@ export interface StoredImageAttachment { data: Uint8Array } -/** Provider-resolved ways for model tools to access one normalized attachment. */ -export interface ImageAttachmentAccess { - /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ - readonlyPath: string -} - /** Deterministic request-image policy selected by one exact model route. */ export interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ @@ -87,8 +81,6 @@ export interface RequestImageAttachment { variantId: ImageVariantId /** Durable normalized attachment from which this request version was derived. */ attachment: ImageAttachmentRef - /** Transient provider access facts; never persisted in the durable reference. */ - access?: ImageAttachmentAccess /** Encoded request bytes. */ data: Uint8Array mediaType: ImageMediaType diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index a28bcd6c1c..8675d45404 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -147,10 +147,10 @@ describe('AttachmentStore.readImageRequest', () => { expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) - it('exposes no provider-specific path by default', async () => { + it('exposes no provider-owned host path by default', async () => { const store = new RecordingStore(new Context()) const ref = await store.saveImage(image(1)) - expect(store.imageAccess(ref)).toBeUndefined() + expect(store.imageHostPath(ref)).toBeUndefined() }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 57d9ab16d6..0798c8f31c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -450,10 +450,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['the signal reason when aborted, or a storage error when verification fails.'], }, { - signature: 'imageAccess(ref: ImageAttachmentRef): ImageAttachmentAccess | undefined', - description: 'Resolve provider-specific model-tool access without adding host facts to session history.', + signature: 'imageHostPath(ref: ImageAttachmentRef): string | undefined', + description: 'Locate the provider-owned normalized object in the harness host filesystem.', parameters: [{ name: 'ref', description: 'durable normalized attachment reference.' }], - returns: 'current-provider access facts, or undefined when this backend exposes no local path.', + returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', throws: ['an AttachmentError when the durable reference is invalid.'], }, { @@ -3726,10 +3726,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GrantRecord', declaration: 'export interface GrantRecord {\n readonly kind: \'grant\';\n readonly payload: unknown;\n}', }, - { - name: 'ImageAttachmentAccess', - declaration: 'export interface ImageAttachmentAccess {\n readonlyPath: string;\n}', - }, { name: 'ImageAttachmentLimits', declaration: 'export interface ImageAttachmentLimits {\n maxImageBytes: number;\n maxImagesPerMessage: number;\n maxMessageImageBytes: number;\n maxImagePixels: number;\n maxImageDimension: number;\n mediaTypes: readonly ImageMediaType[];\n}', @@ -4236,7 +4232,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestImageAttachment', - declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n access?: ImageAttachmentAccess;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', + declaration: 'export interface RequestImageAttachment {\n variantId: ImageVariantId;\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n depth: \'uchar\';\n space: \'srgb\';\n hasAlpha: boolean;\n}', }, { name: 'RequestRunOutcome', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index dc0fe9ccb6..75d595a147 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 31aa82dcc0dde68b7ed23edcd0dc477320d55505 -README.zh.md: dad1dbcea639130ff55430684d6478a86717154c +README.md: 629a1ebe00e5b14ac575a7b088a2e737e89d0c07 +README.zh.md: d5f11f8f2f8b9053685ba553a69fe71e40458ca3 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 31aa82dcc0..629a1ebe00 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,7 +52,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id, actual request dimensions, and the local normalized-object path when its attachment provider exposes one. The text marks that path read-only, gives the matching extension for a writable copy, and states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by text naming the complete attachment id and actual request dimensions. When the attachment provider exposes a host object and the current filesystem maps it into the tool execution world, the text also includes that read-only path and the matching extension for a writable copy. This access is resolved independently from the deterministic request version and its `variantId`. The descriptor states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Each removed image becomes its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only local path. This high-watermark projection avoids changing an old request prefix after every new image. @@ -115,7 +115,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside stable attachment handles, request-preview dimensions, and the current normalized-object path when available; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the same access facts in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. Provider-specific request extension fields remain outside that model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; a Files resolution failure sends all retained images as inline data URLs instead. The descriptor tells the model that this read-only local copy may be resized or re-encoded and must not be used to infer upload properties. An over-budget older image keeps the access currently resolved for that request in its per-image placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. #### Token effect @@ -123,7 +123,7 @@ Provider tokenization governs exact text and image-token input. Reasoning passba #### KV Cache effect -An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends on every reasoned turn. +An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. Deterministic request-image bytes do not make the complete prefix immutable: a changed execution-world path rewrites historical descriptor text even without offload, a refreshed upload can replace a `file_id`, and Files-to-base64 fallback changes the image representation. Any of these may prevent reuse from the first affected image. Model-route, prompt, schema, history, and image-budget changes have the same suffix effect; reasoning passback appends on every reasoned turn. ### DeepSeek response diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index dad1dbcea6..d5f11f8f2f 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及附件提供方支持时的本地规范化对象路径。文本会把该路径标记为只读,指出复制到可写路径时应使用的匹配扩展名,并说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,文本还会给出该只读路径,并指出复制到可写路径时应使用的匹配扩展名。该访问方式独立于确定性的请求版本及其 `variantId`。描述也会说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 `maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。每张被移除的图片都有自己的模型可见占位文本,其中包含显示名称或附件 ID;如果当前提供方支持,还会包含规范化尺寸、媒体类型和当前只读本地路径。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 @@ -115,7 +115,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有稳定附件句柄、请求预览尺寸,以及当前可用的规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留相同的访问事实。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。提供方特定请求扩展字段仍位于该模型输入之外。视觉模型通常通过 Files API 引用收到保留的 user 与工具结果图片,旁边带有附件句柄和请求预览尺寸。当前执行文件系统能够映射附件提供方的宿主对象时,模型还会收到规范化对象路径;Files 解析失败时,所有保留图片改用内联 data URL。描述会告诉模型,该本地副本只供读取,可能经过缩小或重新编码,不能据此推断上传图片的属性。超出上限的较旧图片会在自己的占位文本中保留本次请求解析出的访问方式。之前 assistant 轮次的推理内容会原文回传,无论该轮次是否调用了工具。 #### Token 影响 @@ -123,7 +123,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### KV Cache 影响 -未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在每个含推理的轮次上追加。 +未更改的已组装前缀可以使用 DeepSeek cache 复用,适配器会在 usage 中报告它。确定性的请求图片字节不能保证完整前缀不变:执行环境路径变化会在没有 offload 时改写历史描述,重新上传可能替换 `file_id`,Files 转为 base64 回退也会改变图片表示。这些变化都可能使复用从首张受影响图片起失效。模型路由、提示词、schema、历史和图片上限变化会产生同样的后缀影响;推理回传会在每个含推理的轮次上追加。 ### DeepSeek 响应 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index eba8fbc5a7..49f42e6539 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -57,6 +58,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8b4140a7d1..fccf6793d8 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -12,6 +12,7 @@ import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isCo import type { ContentBlock, GenerateOptions, + ImageAttachmentAccess, LlmModelInfo, LlmProviderInfo, PreparedAdapterCall, @@ -125,6 +126,8 @@ export interface DeepSeekAdapterOptions { resolveUserId: () => AnonymousUserId /** Resolve the current durable attachment service; absence rejects image input. */ resolveAttachments?: () => AttachmentStore | undefined + /** Bridge one attachment reference into the current model-tool execution world. */ + resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined /** Resolve the process-wide upload reuse store. */ resolveFiles?: () => DeepSeekFileStore /** Prepare the official API's plugin-contributed top-level fields for one exact wire request. */ @@ -538,6 +541,10 @@ export class DeepSeekAdapter extends LlmAdapter { const fileConnection = { baseURL: connection.baseURL, apiKey } const model = connection.models.find(entry => entry.id === options.model) const policy = model === undefined ? undefined : resolveRequestImagePolicy(model) + const resolveImageAccess = attachments === undefined + ? undefined + : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) + const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { representation: 'raw', maxBytes: connection.maxRequestFilesBytes, @@ -545,7 +552,7 @@ export class DeepSeekAdapter extends LlmAdapter { byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, byteLength: ref => Math.min(ref.bytes, policy.maxBytes), - placeholder: ref => offloadedImageText(ref, attachments?.imageAccess(ref)), + placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)), }) const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } const requestImages = attachments === undefined || model === undefined @@ -562,6 +569,7 @@ export class DeepSeekAdapter extends LlmAdapter { body = await serializeRequestWithImages(requestOptions, { representation: { kind: 'base64' }, requestImages, + ...imageAccessOptions, maxRequestImageBytes: connection.maxInlineRequestImageBytes, maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.inlineImageOffloadByteQuantum, @@ -592,6 +600,7 @@ export class DeepSeekAdapter extends LlmAdapter { }, }, requestImages, + ...imageAccessOptions, maxRequestImageBytes: connection.maxRequestFilesBytes, maxImagesPerRequest: connection.maxImagesPerRequest, byteQuantum: connection.imageOffloadByteQuantum, diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 4f5864aa6d..0d895a5e3b 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -13,8 +13,9 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-fs' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -438,6 +439,11 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey, resolveUserId, resolveAttachments: () => ctx.get('attachments'), + resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( + attachments, + hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), + ref, + ), prepareExtensions: (request) => { const extensions = ctx.get('deepseekLlmApiExtensions') return extensions?.prepare(request) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index e1218ba16c..1749991eca 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -7,7 +7,7 @@ */ import { contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import type { WireImageContentPart, @@ -48,6 +48,8 @@ export interface ImageSerializationOptions { representation: ImageRequestRepresentation /** Request versions prepared for the conservatively retained normalized attachments, keyed by attachment id. */ requestImages: ReadonlyMap + /** Resolve current tool access independently from deterministic request-image versions. */ + resolveImageAccess?: ImageAttachmentAccessResolver /** Positive bound on accumulated represented image bytes. */ maxRequestImageBytes: number /** Maximum represented images in one request. */ @@ -127,11 +129,12 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { function imageHandle( ref: ImageAttachmentRef, version: RequestImageAttachment, + resolveAccess: ImageAttachmentAccessResolver | undefined, precededByContent: boolean, ): WireTextContentPart { return { type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version)}`, + text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version, resolveAccess?.(ref))}`, } } @@ -155,7 +158,7 @@ async function imageParts( type: 'image_url', image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, } - return [imageHandle(block.attachment, version, precededByContent), image] + return [imageHandle(block.attachment, version, images.resolveImageAccess, precededByContent), image] } /** Convert user or nested tool-result blocks into ordered wire parts. */ @@ -390,10 +393,10 @@ export function serializeRequest( /** * Build one image-capable request while keeping durable bytes out of session - * messages. Oversized oldest images become deterministic text after their + * messages. Oversized oldest images become per-image text after their * exact request-version byte lengths are known and before provider serialization. * @param options - harness request containing image-capable user content. - * @param images - attachment resolver, request bound, and cancellation. + * @param images - request versions, optional current access resolver, and request bounds. * @param defaults - adapter-level thinking defaults. * @returns the fully materialized DeepSeek request body. */ @@ -416,10 +419,7 @@ export async function serializeRequestWithImages( ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, - placeholder: (ref) => { - const version = images.requestImages.get(ref.attachmentId) - return offloadedImageText(ref, version?.access) - }, + placeholder: ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)), }) const messages: WireMessage[] = [] if (options.system !== undefined) { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 362a90dadb..9366c32ce8 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -109,7 +109,7 @@ function attachmentStoreOf( } { const readImageRequest = vi.fn(project) return { - store: { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore, + store: { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore, readImageRequest, } } diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index cb94b3c237..0240a6c22a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -57,7 +57,7 @@ function imageOptions( refs: readonly ImageAttachmentRef[], resolveFileId: FileResolver = fileResolver(), maxRequestImageBytes = 20 * 1024 * 1024, -) { +): ImageSerializationOptions { return { representation: { kind: 'file' as const, resolveFileId }, requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), @@ -426,7 +426,7 @@ describe('image serialization', () => { const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment version.width = 1130 version.height = 565 - version.access = { readonlyPath: '/tmp/dsh/objects/aa/object' } + images.resolveImageAccess = () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }) const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ @@ -594,8 +594,9 @@ describe('image serialization', () => { const png = imageRef('image/png', 3) const jpeg = imageRef('image/jpeg', 3) const images = imageOptions([png, jpeg], resolveFileId, 4) - const pngVersion = images.requestImages.get(png.attachmentId) as RequestImageAttachment - pngVersion.access = { readonlyPath: '/tmp/dsh/objects/png' } + images.resolveImageAccess = ref => ref.mediaType === 'image/png' + ? { readonlyPath: '/tmp/dsh/objects/png' } + : undefined const wire = await serializeRequestWithImages(request({ model: 'deepseek-v4-flash-vision-exp', messages: [createUserMessage({ diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 2f75c10b9e..81b7bb1eca 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../fs/fs" + }, { "path": "../../util/atomic-write" }, diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 67377a2daa..6121212098 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 7f0c2a32315200e3244cb83d396bbf2ee30d728f -README.zh.md: f11d4ba1d991cc70c1967eef8f8b880727df36ce +README.md: 1ae0e641f7ffaebc1c2c1060e143c72a40631396 +README.zh.md: 17bf059ea9bb3fbbb80cadc0c9eefed76af26abd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 7f0c2a3231..1ae0e641f7 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64, and its stable descriptor exposes the attachment id, actual request-image dimensions, and provider-resolved normalized-object path when available. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64. Its descriptor exposes the attachment id and actual request-image dimensions, plus a normalized-object path only when the attachment provider exposes a host object and the current filesystem maps it into the tool execution world. The path is resolved separately from the request version and its `variantId`. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -173,7 +173,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by stable text naming its complete attachment id, actual request dimensions, and current normalized-object path when available. The descriptor marks that path read-only and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and available local path in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. The text includes a normalized-object path when the current execution filesystem maps the attachment provider's host object, marks that path read-only, and warns that normalization or request projection may have resized or re-encoded the upload. When accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image keeps its own identity and access currently resolved for that request in replacement text. Offloaded normalized attachments are not read or transformed. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect @@ -181,7 +181,7 @@ Provider tokenization governs exact input. Retained images add the stable attach #### KV Cache effect -Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. Crossing the image bound rewrites an early message (the newly offloaded image becomes placeholder text), so reuse ends at that message until the offloaded prefix stabilizes. +Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. Stable attachment identity and request bytes do not make that text immutable: a changed execution-world path rewrites a historical handle even without offload and may prevent reuse from that image. Changing adapter instance, provider, model, or any other upstream request token has the same suffix effect. Crossing the image bound replaces an earlier image with placeholder text, so reuse ends at that message until the offloaded prefix stabilizes. ### Provider response diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index f11d4ba1d9..17bf059ea9 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64,其稳定描述会公开附件 ID、实际请求图片尺寸,以及当前提供方支持时的规范化对象路径。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64。对应描述会公开附件 ID 和实际请求图片尺寸;只有附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,描述才会加入规范化对象路径。该路径独立于请求版本及其 `variantId`。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -174,7 +174,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有稳定文本,写明完整附件 ID、实际请求尺寸,以及当前可用的规范化对象路径。描述会把该路径标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和可用本地路径。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。当前执行文件系统能够映射附件提供方的宿主对象时,文本还会给出规范化对象路径,将其标记为只读,并说明规范化或请求投影可能缩小或重新编码上传图片。请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,每张被 offload 的图片会在替代文本中保留自己的身份和本次请求解析出的访问方式。系统不会读取或转换被 offload 的规范化附件。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 @@ -182,7 +182,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### KV Cache 影响 -转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使复用从首个出现差异的 token 起失效。跨过图片上限会改写较早的一条消息(新被 offload 的图片变为占位文本),复用在该消息处截止,直到被 offload 的前缀稳定。 +转换保留逻辑请求顺序,图片句柄和 offload 占位内容会加入模型可见文本。稳定的附件身份和请求字节不能保证这些文本不变:执行环境路径变化会在没有 offload 时改写历史句柄,并可能使复用从该图片起失效。更改适配器实例、提供方、模型或其他上游请求 token 会产生同样的后缀影响。跨过图片上限会把较早图片替换为占位文本,复用在该消息处截止,直到被 offload 的前缀稳定。 ### 提供方响应 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index d0755b06cc..ce809946be 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 37d4ea48f6..e20b8e0072 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -47,6 +47,7 @@ import { } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, + ImageAttachmentAccess, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, @@ -55,7 +56,7 @@ import type { ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' @@ -93,6 +94,8 @@ export interface PiAiAdapterOptions { auth: PiAiAuthInjection /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined + /** Bridge one attachment reference into the current model-tool execution world. */ + resolveImageAccess?: (attachments: AttachmentStore, ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined /** * Observe one assistant history message degrading to provider-neutral * conversion because its stored replay state is unusable by this build. @@ -360,10 +363,15 @@ export class PiAiAdapter extends LlmAdapter { } const context = attachments === undefined ? toPiContext(options, undefined, onReplayDegrade) - : await toPiContext({ ...options, signal: watchdog.signal }, attachments, onReplayDegrade, profile.maxRequestImageBytes, { - maxPixels: profile.requestImagePixelBudget, - maxBytes: profile.requestImageMaxBytes, - }) + : await toPiContext({ ...options, signal: watchdog.signal }, { + attachments, + resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref), + maxRequestImageBytes: profile.maxRequestImageBytes, + requestImagePolicy: { + maxPixels: profile.requestImagePixelBudget, + maxBytes: profile.requestImageMaxBytes, + }, + }, onReplayDegrade) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index db99f86d8e..4315cc690e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -5,7 +5,7 @@ */ import { CallId, contentHasImage, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, requestImageHandleText } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentId, AttachmentStore, @@ -48,6 +48,7 @@ function assertSupportedImageRoles(messages: readonly Message[]): void { async function userContent( blocks: readonly ContentBlock[], requestImages: ReadonlyMap, + resolveImageAccess: ImageAttachmentAccessResolver, ): Promise { const content: (TextContent | ImageContent)[] = [] for (const block of blocks) { @@ -57,7 +58,10 @@ async function userContent( break case 'image': { const version = requestImages.get(block.attachment.attachmentId) as RequestImageAttachment - content.push({ type: 'text', text: requestImageHandleText(block.attachment, version) }) + content.push({ + type: 'text', + text: requestImageHandleText(block.attachment, version, resolveImageAccess(block.attachment)), + }) content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), @@ -67,7 +71,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(block.content, requestImages) + const nested = await userContent(block.content, requestImages, resolveImageAccess) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -170,17 +174,29 @@ function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: st return piContext(options, messages) } +/** Inputs that bind deterministic request images to one current tool execution world. */ +export interface PiImageRequestContext { + /** Durable provider that resolves request-image bytes and provider-owned host objects. */ + attachments: AttachmentStore + /** Resolve current tool access separately from deterministic request-image versions. */ + resolveImageAccess: ImageAttachmentAccessResolver + /** Request-level bound on base64-encoded image payload; omission leaves every image in place. */ + maxRequestImageBytes?: number + /** Route pixel and raw encoded-byte budgets. */ + requestImagePolicy?: ImageRequestPolicy +} + /** * Convert text-only harness history to a synchronous pi-ai Context. Tool * result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @param attachments - absent; selects the synchronous conversion. + * @param images - absent; selects the synchronous conversion. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @returns the pi-ai context; `tools` is omitted when the request declares none. */ export function toPiContext( options: GenerateOptions, - attachments?: undefined, + images?: undefined, onReplayDegrade?: (reason: string) => void, ): PiContext /** @@ -190,48 +206,42 @@ export function toPiContext( * oldest images are replaced by text placeholders until the request fits, so * an image-heavy session keeps clearing gateway request-size caps. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @param attachments - durable byte resolver for image references. + * @param images - attachment provider, current path resolver, and request limits. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. - * @param maxRequestImageBytes - request-level bound on base64-encoded image payload; omission leaves every image in place. - * @param requestImagePolicy - route pixel and raw encoded-byte budgets. * @returns the asynchronously resolved pi-ai context. */ export function toPiContext( options: GenerateOptions, - attachments: AttachmentStore, + images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy?: ImageRequestPolicy, ): Promise export function toPiContext( options: GenerateOptions, - attachments?: AttachmentStore, + images?: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy?: ImageRequestPolicy, ): PiContext | Promise { - return attachments === undefined + return images === undefined ? textOnlyContext(options, onReplayDegrade) - : toPiContextWithImages(options, attachments, onReplayDegrade, maxRequestImageBytes, requestImagePolicy) + : toPiContextWithImages(options, images, onReplayDegrade) } async function toPiContextWithImages( options: GenerateOptions, - attachments: AttachmentStore, + images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, - maxRequestImageBytes?: number, - requestImagePolicy: ImageRequestPolicy = { +): Promise { + const { attachments, resolveImageAccess, maxRequestImageBytes } = images + const requestImagePolicy = images.requestImagePolicy ?? { maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, - }, -): Promise { + } assertSupportedImageRoles(options.messages) const requestMessages = offloadRequestImagesWithPolicy(options.messages, { representation: 'base64', ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => Math.min(ref.bytes, requestImagePolicy.maxBytes), - placeholder: ref => offloadedImageText(ref, attachments.imageAccess(ref)), + placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), }) const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal) const exactMessages = offloadRequestImagesWithPolicy(requestMessages, { @@ -239,7 +249,7 @@ async function toPiContextWithImages( ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes }, byteQuantum: 1, byteLength: ref => (requestImages.get(ref.attachmentId) as RequestImageAttachment).bytes, - placeholder: ref => offloadedImageText(ref, requestImages.get(ref.attachmentId)?.access), + placeholder: ref => offloadedImageText(ref, resolveImageAccess(ref)), }) const toolNames = new Map() const messages: PiMessage[] = [] @@ -262,7 +272,7 @@ async function toPiContextWithImages( } // user role: text + tool results (each result becomes its own message). const regular = message.content.filter(block => block.type !== 'tool-result') - const content = await userContent(regular, requestImages) + const content = await userContent(regular, requestImages, resolveImageAccess) const results = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) @@ -270,7 +280,7 @@ async function toPiContextWithImages( messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { - const resultContent = await userContent(result.content, requestImages) + const resultContent = await userContent(result.content, requestImages, resolveImageAccess) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 846509f8c6..c9752b764e 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -57,8 +57,9 @@ import type { Context } from '@deepseek-ai/cordis' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' +import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-fs' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { PiAiAdapter } from './adapter.ts' import { authContextFrom, credentialStoreFrom } from './auth.ts' @@ -197,6 +198,11 @@ export function apply(ctx: Context, config: Config): void { resolveApiKey, auth, resolveAttachments: () => ctx.get('attachments'), + resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( + attachments, + hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath), + ref, + ), onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn( `llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";` diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index a971b80c85..4e6e29c118 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -9,6 +9,7 @@ import type { import { CallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { toPiContext } from '../src/context.ts' +import type { PiImageRequestContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' const ref: ImageAttachmentRef = { @@ -43,11 +44,18 @@ function projectionStore( Promise.resolve(requestImage(value, Uint8Array.of(1))) )), ): AttachmentStore { - return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore + return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore } const attachments = projectionStore() +function imageContext( + store: AttachmentStore, + overrides: Partial> = {}, +): PiImageRequestContext { + return { attachments: store, resolveImageAccess: () => undefined, ...overrides } +} + function request(messages: GenerateOptions['messages']): GenerateOptions { return { provider: 'openai', @@ -138,7 +146,7 @@ describe('pi-ai request context conversion', () => { { type: 'image', attachment: ref }, ], }]), - ]), attachments) + ]), imageContext(attachments)) expect(context.messages).toEqual([ { role: 'user', content: '', timestamp: 0 }, @@ -180,9 +188,10 @@ describe('pi-ai request context conversion', () => { ...requestImage(value, Uint8Array.of(1)), width: 1130, height: 565, - access: { readonlyPath: '/tmp/dsh/objects/aa/object' }, })) - const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), store) + const context = await toPiContext(request([user([{ type: 'image', attachment: named }])]), imageContext(store, { + resolveImageAccess: () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }), + })) expect(context.messages[0]).toMatchObject({ role: 'user', content: [ @@ -211,7 +220,7 @@ describe('pi-ai request context conversion', () => { content: [{ type: 'image', attachment: ref }], }, ], - }])]), attachments) + }])]), imageContext(attachments)) expect(context.messages).toEqual([{ role: 'toolResult', @@ -265,7 +274,7 @@ describe('pi-ai request context conversion', () => { }]), user([{ type: 'image', attachment: sized }, { type: 'text', text: 'newer' }]), user([{ type: 'image', attachment: sized }]), - ]), store, undefined, 8) + ]), imageContext(store, { maxRequestImageBytes: 8 })) expect(context.messages).toEqual([ { @@ -308,7 +317,7 @@ describe('pi-ai request context conversion', () => { const context = await toPiContext(request([user([ { type: 'image', attachment: old }, { type: 'image', attachment: recent }, - ])]), projectionStore(readImageRequest), undefined, 4) + ])]), imageContext(projectionStore(readImageRequest), { maxRequestImageBytes: 4 })) expect(context.messages[0]).toMatchObject({ role: 'user', @@ -322,17 +331,19 @@ describe('pi-ai request context conversion', () => { expect(readImageRequest.mock.calls[0]?.[0]).toEqual(recent) }) - it('uses the generated request access when exact encoded bytes require offload', async () => { + it('uses independently resolved access when exact encoded bytes require offload', async () => { const sized: ImageAttachmentRef = { ...ref, bytes: 3 } const access = { readonlyPath: '/tmp/dsh-normalized-image' } const readImageRequest = vi.fn((value: ImageAttachmentRef) => Promise.resolve({ ...requestImage(value, Uint8Array.of(1, 2, 3, 4)), - access, })) const context = await toPiContext(request([ user([{ type: 'image', attachment: sized }]), - ]), projectionStore(readImageRequest), undefined, 4) + ]), imageContext(projectionStore(readImageRequest), { + maxRequestImageBytes: 4, + resolveImageAccess: () => access, + })) expect(context.messages).toEqual([{ role: 'user', @@ -347,7 +358,7 @@ describe('pi-ai request context conversion', () => { const exact = await toPiContext(request([ user([{ type: 'image', attachment: sized }]), user([{ type: 'image', attachment: sized }]), - ]), attachments, undefined, 8) + ]), imageContext(attachments, { maxRequestImageBytes: 8 })) expect(exact.messages).toEqual([ { role: 'user', @@ -367,7 +378,7 @@ describe('pi-ai request context conversion', () => { const store = projectionStore(readImageRequest) const oversized = await toPiContext(request([ user([{ type: 'image', attachment: { ...ref, bytes: 300 } }]), - ]), store, undefined, 8) + ]), imageContext(store, { maxRequestImageBytes: 8 })) // All-text content collapses to the string form; the placeholder still reaches the model. expect(oversized.messages).toEqual([ { role: 'user', content: offloadedImageText({ ...ref, bytes: 300 }), timestamp: 0 }, @@ -382,11 +393,14 @@ describe('pi-ai request context conversion', () => { Promise.resolve(requestImage(value, Uint8Array.of(1, 2, 3))) )) const store = projectionStore(readImageRequest) - const aliased = await toPiContext(request([user([shared, shared])]), store, undefined, 4) + const aliased = await toPiContext( + request([user([shared, shared])]), + imageContext(store, { maxRequestImageBytes: 4 }), + ) const replayed = await toPiContext(request([user([ { type: 'image', attachment: { ...sized } }, { type: 'image', attachment: { ...sized } }, - ])]), store, undefined, 4) + ])]), imageContext(store, { maxRequestImageBytes: 4 })) const expected = [{ role: 'user', @@ -430,7 +444,7 @@ describe('pi-ai request context conversion', () => { const store = projectionStore(readImageRequest) await expect(toPiContext(request([ history(role, [{ type: 'image', attachment: ref }]), - ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + ]), imageContext(store, { maxRequestImageBytes: 1 }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) expect(readImageRequest).not.toHaveBeenCalled() } @@ -438,7 +452,7 @@ describe('pi-ai request context conversion', () => { history('system', [{ type: 'text', text: 'history system' }]), history('assistant', [{ type: 'text', text: 'answer' }]), user([{ type: 'text', text: 'plain' }]), - ]), attachments)).resolves.toMatchObject({ + ]), imageContext(attachments))).resolves.toMatchObject({ messages: [ { role: 'user', content: 'history system' }, { role: 'assistant' }, diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 1d53dbdab0..6967ea8fd6 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -63,7 +63,11 @@ function attachmentStore(readImageRequest: ( policy: ImageRequestPolicy, signal?: AbortSignal, ) => Promise): AttachmentStore { - return { readImageRequest, imageAccess: () => undefined } as unknown as AttachmentStore + return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore +} + +function imageContext(attachments: AttachmentStore) { + return { attachments, resolveImageAccess: () => undefined } } describe('toPiContext', () => { @@ -109,7 +113,7 @@ describe('toPiContext', () => { content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], source: { kind: 'plugin', plugin: 'test' }, })], - }, attachmentStore(readImageRequest)) + }, imageContext(attachmentStore(readImageRequest))) expect(readImageRequest).toHaveBeenCalledWith( attachment, @@ -161,7 +165,7 @@ describe('toPiContext', () => { }], source: { kind: 'plugin', plugin: 'test' }, })], - }, attachmentStore(readImageRequest)) + }, imageContext(attachmentStore(readImageRequest))) expect(context.messages).toEqual([{ role: 'toolResult', diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 172dbe8a6e..8200210b6d 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../attachment/attachment" }, + { + "path": "../../fs/fs" + }, { "path": "../../credentials/credentials" }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 3ac89ec262..bec2fe9b81 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/llm/llm/README.md -README.md: d08d28497c4821c578fac43515b3bebfa4ed8131 -README.zh.md: 3d7a183aa2319acd869d8e4bc68ec9493c3ea322 +README.md: ef58516790a2723bce34aa1bbae2e1629050f6a5 +README.zh.md: 8af1240cdb4f3b65f1c0f841ade620c85443129b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d08d28497c..ef58516790 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -57,7 +57,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o Message content is an array of typed blocks: `text`, `reasoning`, `image`, `tool-call`, `tool-result`. An `ImageBlock` carries only a durable `ImageAttachmentRef`; provider bytes and request dimensions are resolved later. The union remains merge-extensible through `ContentBlockMap`, so plugins can add further block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. -Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text. +Every dispatch uses the exact model modalities captured with its adapter generation. An image-capable adapter projects durable image references into route-specific request versions. `resolveImageAttachmentAccess()` separately combines an attachment provider's optional host object with a consumer-supplied mapping from that host path into the current tool execution world. The result never enters `RequestImageAttachment` or its `variantId`. A text-only route instead receives deterministic attachment placeholders, including nested tool-result images, without changing append-only session history. `offloadRequestImagesWithPolicy()` provides deterministic oldest-first image removal with raw or base64 accounting and count or byte quanta; adapters supply the exact derived-version byte length and the required per-image placeholder text. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content. @@ -95,7 +95,7 @@ None, as adapters choose when to add the shared image descriptors and per-image #### KV Cache effect -Reasoning-effort materialization preserves the assembled request prefix. Image descriptors add deterministic text beside each image, and a request-limit transition replaces the deterministic oldest prefix with per-image text. +Reasoning-effort materialization preserves the assembled request prefix. Image identity and request-preview text are deterministic, while the optional execution-world path is resolved for each request. A changed path can alter a historical descriptor and prevent reuse from that image even without offload. Crossing a request limit also replaces an older image with per-image text. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 3d7a183aa2..8af1240cdb 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -57,7 +57,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`image`、`tool-call`、`tool-result`。`ImageBlock` 只携带持久 `ImageAttachmentRef`;提供方字节和请求尺寸之后再解析。联合仍从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加其他块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型或提供方间恢复或转换该状态。 -每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。 +每次分发都使用随适配器世代捕获的确切模型模态。支持图片的适配器把持久图片引用投影为路由专用请求版本。`resolveImageAttachmentAccess()` 单独组合附件提供方可选的宿主对象,以及消费方给出的宿主路径到当前工具执行环境的映射。解析结果不进入 `RequestImageAttachment` 或其 `variantId`。纯文本路由则收到确定性的附件占位文本,其中也包括嵌套工具结果图片,追加式会话历史不会改变。`offloadRequestImagesWithPolicy()` 提供确定性的从旧到新图片移除,支持按原始字节或 base64 计数,也支持图片数量或字节量步长;适配器提供确切派生版本的字节长度和必填的逐图占位文本。 流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。 @@ -97,7 +97,7 @@ #### KV Cache 影响 -推理强度填入不会改变已组装的请求前缀。图片描述会在每张图片旁加入确定性文本;请求越过上限时,确定性的最旧前缀会替换为逐图文本。 +推理强度填入不会改变已组装的请求前缀。图片身份和请求预览文本具有确定性,可选的执行环境路径则按请求解析。路径变化会改写历史图片描述,即使没有 offload,也可能使缓存从该图片起无法复用。请求越过上限时,较旧图片也会替换为逐图文本。 diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 10076ad529..ed97a9b6f1 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,9 +2,43 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { ImageAttachmentAccess, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { assertNever } from './never.ts' +/** Execution-world path that model tools can use to read one normalized attachment. */ +export interface ImageAttachmentAccess { + /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */ + readonlyPath: string +} + +/** + * Resolve current execution-world access for one durable image reference. + * @param ref - durable normalized attachment reference. + * @returns a read-only execution-world path, or undefined when unavailable. + */ +export type ImageAttachmentAccessResolver = (ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined + +/** + * Bridge one attachment provider's host object location into the mounted + * tool execution world. The consumer supplies the current filesystem + * provider's mapping without making attachment or LLM definitions depend on it. + * @param attachments - provider that owns the normalized attachment object. + * @param mapHostPath - map one absolute host path into the current tool execution world. + * @param ref - durable normalized attachment reference. + * @returns a read-only execution-world path, or undefined when either provider exposes no mapping. + * @throws an attachment error when the durable reference is invalid. + */ +export function resolveImageAttachmentAccess( + attachments: AttachmentStore, + mapHostPath: (hostPath: string) => string | undefined, + ref: ImageAttachmentRef, +): ImageAttachmentAccess | undefined { + const hostPath = attachments.imageHostPath(ref) + if (hostPath === undefined) return undefined + const readonlyPath = mapHostPath(hostPath) + return readonlyPath === undefined ? undefined : { readonlyPath } +} + function quoted(value: string): string { return JSON.stringify(value) } @@ -48,13 +82,18 @@ export function textOnlyImageText(ref: ImageAttachmentRef): string { * names differ. * @param ref - the occurrence's durable normalized attachment. * @param version - exact request image shown beside the text. + * @param access - optional path resolved for the current tool execution world. * @returns attachment handle and request-image dimensions. */ -export function requestImageHandleText(ref: ImageAttachmentRef, version: RequestImageAttachment): string { +export function requestImageHandleText( + ref: ImageAttachmentRef, + version: RequestImageAttachment, + access?: ImageAttachmentAccess, +): string { const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.` - return version.access === undefined + return access === undefined ? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.` - : preview + normalizedAccessText(ref, version.access) + : preview + normalizedAccessText(ref, access) } /** diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 236cee2297..3391423bdb 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { CallId, createUserMessage, offloadedImageText, offloadRequestImagesWithPolicy, projectImagesForTextModel, + resolveImageAttachmentAccess, requestImageHandleText, } from '../src/index.ts' import type { ContentBlock, Message } from '../src/index.ts' @@ -186,10 +187,10 @@ describe('model-facing image access', () => { height: 1536, name: 'source "map".png', } + const access = { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' } const version = { variantId: ImageVariantId(`sha256:${'c'.repeat(64)}`), attachment, - access: { readonlyPath: '/tmp/.dsh/attachments/v1/objects/bb/object' }, data: Uint8Array.of(1), mediaType: 'image/png' as const, bytes: 1, @@ -199,7 +200,7 @@ describe('model-facing image access', () => { space: 'srgb' as const, hasAlpha: true, } - expect(requestImageHandleText(attachment, version)).toBe( + expect(requestImageHandleText(attachment, version, access)).toBe( `Image "source \\"map\\".png" (${attachment.attachmentId}); request preview 923x692px.` + ' Normalized copy (read-only; may be resized or re-encoded): "/tmp/.dsh/attachments/v1/objects/bb/object" (2048x1536px, image/png).' + ' Source dimensions, format, and byte size may differ.' @@ -207,6 +208,31 @@ describe('model-facing image access', () => { ) }) + it('bridges a provider host object only through the mounted filesystem mapping', () => { + const attachment = image(1).attachment + const attachments = { + imageHostPath: () => '/host/.dsh/attachments/object', + } as unknown as AttachmentStore + const mapped = (hostPath: string): string | undefined => hostPath === '/host/.dsh/attachments/object' + ? '/workspace/.attachments/object' + : undefined + expect(resolveImageAttachmentAccess( + attachments, + mapped, + attachment, + )).toEqual({ readonlyPath: '/workspace/.attachments/object' }) + expect(resolveImageAttachmentAccess( + attachments, + () => undefined, + attachment, + )).toBeUndefined() + expect(resolveImageAttachmentAccess( + { imageHostPath: () => undefined } as unknown as AttachmentStore, + mapped, + attachment, + )).toBeUndefined() + }) + it('names each occurrence from its own reference when one prepared version is shared', () => { const attachment = { attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eebeb12111..9aef169a41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1136,12 +1136,6 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../../fs/fs - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths @@ -6189,6 +6183,9 @@ importers: '@deepseek-ai/dsh-deepseek-llm-api-extensions': specifier: workspace:^ version: link:../deepseek-llm-api-extensions + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths @@ -6238,6 +6235,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 31217c1f31..db52420840 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -335,7 +335,7 @@ export const LINK_MAP: Readonly> = { ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', EncodedImageAttachment: 'attachment.md', - ImageAttachmentAccess: 'attachment.md', + ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', RequestImageAttachment: 'attachment.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0195425c04..3bedaa9804 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -36,6 +36,11 @@ "symbol": "ContextFormed", "source": "packages/llm/llm/src/message.ts" }, + { + "doc": "docs/subsystems/llm-streaming.md", + "symbol": "ImageAttachmentAccess", + "source": "packages/llm/llm/src/content.ts" + }, { "doc": "docs/subsystems/llm-streaming.md", "symbol": "FinishReasonMap", @@ -931,11 +936,6 @@ "symbol": "StoredImageAttachment", "source": "packages/attachment/attachment/src/types.ts" }, - { - "doc": "docs/subsystems/attachment.md", - "symbol": "ImageAttachmentAccess", - "source": "packages/attachment/attachment/src/types.ts" - }, { "doc": "docs/subsystems/attachment.md", "symbol": "ImageRequestPolicy", From 65a8d6be1be1f422286673cdc46be6b2096e7959 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 16:34:42 +0800 Subject: [PATCH 07/42] feat(client): show build version in local banner --- ...6-08-18-client-build-environment.i18n.yaml | 4 +- .../2026-08-18-client-build-environment.md | 8 +- .../2026-08-18-client-build-environment.zh.md | 8 +- apps/web/tests/built-boot.snapshot.ts | 42 ++++++- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 2 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 22 +++- .../sidebar-snapshot.client.spec.tsx.snap | 12 +- .../tests/sidebar-root.client.spec.tsx | 35 +++++- .../tests/sidebar-snapshot.client.spec.tsx | 6 +- scripts/build.ts | 13 +- .../client-build-environment.client.spec.ts | 114 ++++++++++++++++++ scripts/client-build-environment.ts | 86 ++++++++++++- scripts/dev-web.spec.ts | 37 +++++- scripts/dev-web.ts | 28 +++++ 20 files changed, 389 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml index b97fb2d1e8..e778ec3085 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.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/architecture/2026-08-18-client-build-environment.md -2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431 -2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328 +2026-08-18-client-build-environment.md: 590f53521476b5195acbad73c452fac3b2f8f914 +2026-08-18-client-build-environment.zh.md: 8803463663d3d3bd08f5abdbb6f54ed3795fe6a8 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md index 45ed6c8bc6..590f535214 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -18,7 +18,9 @@ The Vite config and the shared tsdown preset for dynamic client bundles use one The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it. -The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. +The root build wrapper supplies one exact public environment to both bundlers. Every complete build carries the root package version as `DSH_CLIENT_VERSION` and the seven-character source Git HEAD prefix as `DSH_CLIENT_COMMIT_HASH`; an explicit commit supports build environments without repository metadata. A default local build also samples Git status before building and sets `DSH_CLIENT_GIT_DIRTY=true` for any staged, unstaged, untracked, or submodule change. Clean checkouts and sources without Git metadata omit the dirty field. These repository-owned fields replace inherited values, while `pnpm run build` otherwise inherits the caller's remaining `DSH_CLIENT_*` values. + +`pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax. Its exact environment carries the version and commit, sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations, and omits local dirty metadata. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. `pnpm run dev:web` verifies this record before its watcher stages start and reuses its public environment for the lifetime of every stage; it does not resample repository state during incremental rebuilds. ## Alternatives considered @@ -30,8 +32,10 @@ The root build wrapper supplies one exact public environment to both bundlers. I **Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path. +**Resample Git state during every watcher rebuild.** Vite and tsdown fix define substitutions when their long-lived watchers start. Restarting the build pipeline on repository status changes would make an incremental source edit rebuild unrelated artifacts; retaining the last complete build record gives every watcher stage one consistent metadata snapshot. + ## Consequences -The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. +The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its package version and short source revision as public display metadata; a dirty default build additionally identifies its uncommitted source state. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. Watch builds retain their startup snapshot until another complete build replaces the record. Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md index bb96337214..8803463663 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -18,7 +18,9 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define `DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。 -根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。 +根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都以 `DSH_CLIENT_VERSION` 携带根包版本,并以 `DSH_CLIENT_COMMIT_HASH` 携带源码 Git HEAD 的七位前缀;没有仓库元数据的构建环境可显式提供 commit。默认本地构建还会在构建前读取 Git 状态;存在任何暂存、未暂存、未跟踪或子模块变化时设置 `DSH_CLIENT_GIT_DIRTY=true`。没有变化的 worktree 和没有 Git 元数据的源码不携带 dirty 字段。这些由仓库持有的字段会替换继承值,除此之外,`pnpm run build` 继续继承调用方剩余的 `DSH_CLIENT_*` 值。 + +`pnpm run build:official` 不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile。它的精确环境携带版本和 commit,设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用,并省略本地 dirty 元数据。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。`pnpm run dev:web` 在 watcher 启动前验证该记录,并在每个 stage 的整个生命周期内复用其中的公开环境;增量重建期间不会重新读取仓库状态。 ## Alternatives considered @@ -30,8 +32,10 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define **统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。 +**每次 watcher 重建都重新读取 Git 状态。** Vite 和 tsdown 在长驻 watcher 启动时固定 define 替换。仓库状态变化时重启构建流水线,会使一次增量源码修改重建无关产物;保留上次完整构建记录可以让每个 watcher stage 共用同一份元数据快照。 + ## Consequences -Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。 +Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的包版本和短源码 revision;dirty 的默认构建还会标明其源码存在未提交变化。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。watch build 会保留启动时的快照,直到另一次完整构建替换该记录。 任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 96a0a4549b..3a688bbe5c 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -29,22 +29,52 @@ if (!isBuildRecordReader(readClientBuildRecord)) { } const record: unknown = readClientBuildRecord(resolve(import.meta.dirname, '../../..')) if (typeof record !== 'object' || record === null) throw new TypeError('client build record must be an object') -const clientBuildEnvironment: unknown = Reflect.get(record, 'environment') -if (typeof clientBuildEnvironment !== 'object' || clientBuildEnvironment === null) { - throw new TypeError('client build record environment must be an object') -} +const clientBuildEnvironment = requireObject( + Reflect.get(record, 'environment'), + 'client build record environment must be an object', +) function isBuildRecordReader(value: unknown): value is (root: string) => unknown { return typeof value === 'function' } +function requireObject(value: unknown, message: string): Record { + if (!isUnknownRecord(value)) throw new TypeError(message) + return value +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Read one optional string from the verified client build record. */ +function clientBuildValue(name: string): string | undefined { + const value = clientBuildEnvironment[name] + if (value !== undefined && typeof value !== 'string') { + throw new TypeError(`client build record environment ${name} must be a string`) + } + return value +} + it('boots the built plugin graph and renders a fixture session end to end', async () => { mountAssembledApp() // The sidebar renders from the boot graph: every inject layer activated. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull() - expect(screen.queryByText('DSH Local Build')).toBeNull() + if (clientBuildValue('DSH_CLIENT_BUILD_PROFILE') === 'official') { + expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull() + expect(screen.queryByText('DSH Local Build')).toBeNull() + } else { + expect(document.querySelector('svg[viewBox="0 0 23.16 17.04"]')).not.toBeNull() + const version = clientBuildValue('DSH_CLIENT_VERSION') + if (version === undefined) throw new Error('default client build record must carry DSH_CLIENT_VERSION') + const commit = clientBuildValue('DSH_CLIENT_COMMIT_HASH') + const buildVersion = version + + (commit === undefined ? '' : `-${commit}`) + + (clientBuildValue('DSH_CLIENT_GIT_DIRTY') === 'true' ? '-dirty' : '') + screen.getByText('DSH') + screen.getByText(buildVersion) + } // The compact layout dropped group session counts; the fixture workspace // group row renders immediately with its sessions beneath it. const fixtureGroup = (await within(tree).findAllByText('fixture')) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index d8afbed4d5..890c386afe 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: 904245f93546122e4e3a54e020b302a3bcd39d1a -development.zh.md: 0faa0a1e06b6bc2cef23076c004fa4f4ba0a4360 +development.md: 16a2242d5d1b5d91718c6568b351aceb7439f38d +development.zh.md: 0db020bf66f1c16fbc6c987e60f5b61bf052cf62 diff --git a/docs/development.md b/docs/development.md index 904245f935..16a2242d5d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. -`pnpm run build` embeds the caller's exact `DSH_CLIENT_*` environment and uses no public client values when none are set. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build. Each successful complete build writes a gitignored record that binds those values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing, development watchers, and built Web tests reject a missing record or artifacts changed by a later partial build. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. diff --git a/docs/development.zh.md b/docs/development.zh.md index 0faa0a1e06..0db020bf66 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -79,7 +79,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)。 -`pnpm run build` 会内联调用方精确的 `DSH_CLIENT_*` 环境;未设置时不使用任何公开 client 值。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令。每次完整构建成功后都会写入一份被 gitignore 的记录,把这些值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包、开发 watcher 和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 1dc37495d9..d33c1ce612 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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/client/ui-sidebar/README.md -README.md: b924c2e6d18217d9689b7c21137321856e14da2e -README.zh.md: 214a2727243d6d9151e31cfcfbc0bdb886c2fcdf +README.md: 8a7963f1cd5cd0cf9e1d951e68aa53a35f0c4c3c +README.zh.md: 4c9040589ab8a28897821a2e476786ccf8ace50c diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index b924c2e6d1..8a7963f1cd 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a `DSH Local Build` label carrying the build's 7-character `DSH_CLIENT_COMMIT_HASH` badge. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. +The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 214a272724..4c9040589a 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记,以及带有构建期 7 位 `DSH_CLIENT_COMMIT_HASH` 徽标的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`,并把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 及 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 44f3ddeaaa..fc2ec94aaa 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -210,7 +210,7 @@ color: var(--dsw-alias-label-primary); } -.buildRevision { +.buildVersion { display: inline-flex; align-items: center; height: 16px; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index d02518cb2d..3fb2a68fd9 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -34,6 +34,16 @@ const COLLAPSE_SETTLE_MS = 150 */ const SCROLLBAR_LINGER_MS = 2000 +/** Format complete-build metadata for the local brand badge. */ +function localBuildVersion(): string | undefined { + const version = process.env.DSH_CLIENT_VERSION + if (version === undefined) return undefined + const commit = process.env.DSH_CLIENT_COMMIT_HASH + return version + + (commit === undefined ? '' : `-${commit}`) + + (process.env.DSH_CLIENT_GIT_DIRTY === 'true' ? '-dirty' : '') +} + /** * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). @@ -111,6 +121,8 @@ export function SidebarRoot({ } }, [pointerInside]) + const buildVersion = localBuildVersion() + return (
- {t('brand.localBuild')} - {process.env.DSH_CLIENT_COMMIT_HASH - ? {process.env.DSH_CLIENT_COMMIT_HASH} - : null} + + {buildVersion === undefined ? 'DSH Local Build' : 'DSH'} + + {buildVersion === undefined + ? null + : {buildVersion}} ), })} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index ee59061984..7780754125 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -140,12 +140,12 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul - DSH Local Build + DSH - abc1234 + 1.2.3-rc.4-abc1234-dirty
@@ -266,12 +266,12 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo - DSH 本地构建 + DSH - abc1234 + 1.2.3-rc.4-abc1234-dirty diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 3ba66aa4ff..948bd28d2b 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -100,6 +100,8 @@ describe('SidebarRoot shell', () => { it('renders generic brand fallbacks when no package fills the slots', () => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456') + vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true') + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') const { container } = render( { options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) - expect(screen.getByText('DSH Local Build')).toBeTruthy() - expect(screen.getByText('0123456')).toBeTruthy() + expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy() expect(container.querySelector('svg')).not.toBeNull() }) + it.each([ + [{ DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3'], + [{ DSH_CLIENT_COMMIT_HASH: 'abcdef0', DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3-abcdef0'], + ])('omits unavailable build-version suffixes from %j', (environment, expected) => { + for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value) + render( + options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} + />) + + expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText(expected)).toBeTruthy() + }) + + it('retains the local-build fallback without complete build metadata', () => { + render( + options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} + />) + + expect(screen.getByText('DSH Local Build')).toBeTruthy() + }) + it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => { const b = mountShell() expect(b.regionOwner().wide).toBe(true) diff --git a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx index a1c9b22621..a85d31cc02 100644 --- a/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx @@ -20,7 +20,11 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -beforeEach(() => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', 'abc1234') }) +beforeEach(() => { + vi.stubEnv('DSH_CLIENT_COMMIT_HASH', 'abc1234') + vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true') + vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4') +}) afterEach(() => { cleanup() diff --git a/scripts/build.ts b/scripts/build.ts index b8ffa8dbc0..6d6bcb259f 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -6,8 +6,9 @@ import { resolve } from 'node:path' import { parseArgs } from 'node:util' import { CLIENT_BUILD_RECORD_PATH, + CLIENT_BUILD_PROFILE_SELECTOR, clientBuildProcessEnvironment, - repositoryCommitHash, + repositoryClientBuildEnvironment, resolveClientBuildEnvironment, writeClientBuildRecord, } from './client-build-environment.ts' @@ -34,12 +35,10 @@ function main(): void { allowPositionals: false, }) const root = resolve(import.meta.dirname, '..') - const parentEnvironment = { - ...process.env, - DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, process.env), - } - const clientEnvironment = resolveClientBuildEnvironment(parentEnvironment, values.profile) - const buildEnvironment = clientBuildProcessEnvironment(parentEnvironment, clientEnvironment) + const repositoryEnvironment = repositoryClientBuildEnvironment(root, process.env) + const profile = values.profile ?? process.env[CLIENT_BUILD_PROFILE_SELECTOR] + const clientEnvironment = resolveClientBuildEnvironment(repositoryEnvironment, profile) + const buildEnvironment = clientBuildProcessEnvironment(process.env, clientEnvironment) rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true }) runScript('build:lib', buildEnvironment) diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts index e2eb0ebe72..74e8086f43 100644 --- a/scripts/client-build-environment.client.spec.ts +++ b/scripts/client-build-environment.client.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' @@ -7,8 +8,12 @@ import { assertClientBuildEnvironment, clientBuildEnvironmentDefines, clientBuildProcessEnvironment, + officialClientBuildEnvironment, readClientBuildRecord, + repositoryClientBuildEnvironment, repositoryCommitHash, + repositoryGitDirty, + repositoryVersion, resolveClientBuildEnvironment, writeClientBuildRecord, } from './client-build-environment.ts' @@ -51,12 +56,34 @@ function buildFixture(environment: Record): string { return fixtureRoot } +function git(root: string, args: readonly string[]): string { + return execFileSync('git', [...args], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +function repositoryFixture(version = '1.2.3-rc.4'): string { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-repository-')) + roots.push(fixtureRoot) + write(join(fixtureRoot, 'package.json'), `${JSON.stringify({ version })}\n`) + write(join(fixtureRoot, 'tracked.txt'), 'committed\n') + git(fixtureRoot, ['init']) + git(fixtureRoot, ['config', 'user.name', 'DSH test']) + git(fixtureRoot, ['config', 'user.email', 'dsh-test@example.invalid']) + git(fixtureRoot, ['add', 'package.json', 'tracked.txt']) + git(fixtureRoot, ['commit', '-m', 'fixture']) + return fixtureRoot +} + describe('client build environment', () => { it('requires an exact public environment for a named artifact profile', () => { const expected = { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', } as const expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow() @@ -73,7 +100,9 @@ describe('client build environment', () => { DSH_BUILD_CLIENT_PROFILE: 'official', DSH_CLIENT_BUILD_PROFILE: 'local', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_GIT_DIRTY: 'true', DSH_CLIENT_TITLE: 'Local title', + DSH_CLIENT_VERSION: '1.2.3', DSH_CLIENT_EXTRA: 'local-extra', } @@ -84,24 +113,108 @@ describe('client build environment', () => { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', }) expect(() => { resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' }) }).toThrow(/DSH_CLIENT_COMMIT_HASH/) + expect(() => { + resolveClientBuildEnvironment({ + DSH_BUILD_CLIENT_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + }) + }).toThrow(/DSH_CLIENT_VERSION/) expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/) expect(clientBuildProcessEnvironment(parent, { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', })).toEqual({ PATH: '/bin', DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', }) expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7)) }) + it('owns repository version, commit, and dirty metadata for complete builds', () => { + const fixtureRoot = repositoryFixture() + const commit = git(fixtureRoot, ['rev-parse', '--short=7', 'HEAD']) + + expect(repositoryVersion(fixtureRoot)).toBe('1.2.3-rc.4') + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + expect(repositoryClientBuildEnvironment(fixtureRoot, { + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH, + DSH_CLIENT_EXTRA: 'preserved', + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: 'spoofed', + })).toEqual({ + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_EXTRA: 'preserved', + DSH_CLIENT_VERSION: '1.2.3-rc.4', + }) + expect(officialClientBuildEnvironment(fixtureRoot)).toEqual({ + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: commit, + DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3-rc.4', + }) + + write(join(fixtureRoot, '.gitignore'), 'ignored.txt\n') + git(fixtureRoot, ['add', '.gitignore']) + git(fixtureRoot, ['commit', '-m', 'ignore fixture']) + write(join(fixtureRoot, 'ignored.txt'), 'ignored\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + rmSync(join(fixtureRoot, 'ignored.txt')) + + write(join(fixtureRoot, 'tracked.txt'), 'unstaged\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + write(join(fixtureRoot, 'tracked.txt'), 'committed\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + + write(join(fixtureRoot, 'tracked.txt'), 'staged\n') + git(fixtureRoot, ['add', 'tracked.txt']) + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + git(fixtureRoot, ['commit', '-m', 'staged fixture']) + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + + write(join(fixtureRoot, 'untracked.txt'), 'untracked\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + expect(repositoryClientBuildEnvironment(fixtureRoot, { + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH, + })).toEqual({ + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: '1.2.3-rc.4', + }) + + rmSync(join(fixtureRoot, 'untracked.txt')) + const submoduleSource = repositoryFixture('9.8.7') + git(fixtureRoot, ['-c', 'protocol.file.allow=always', 'submodule', 'add', submoduleSource, 'submodule']) + git(fixtureRoot, ['commit', '-am', 'submodule fixture']) + expect(repositoryGitDirty(fixtureRoot)).toBe(false) + write(join(fixtureRoot, 'submodule/tracked.txt'), 'modified submodule\n') + expect(repositoryGitDirty(fixtureRoot)).toBe(true) + }) + + it('omits dirty metadata when repository metadata is unavailable', () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-no-git-')) + roots.push(fixtureRoot) + write(join(fixtureRoot, 'package.json'), '{"version":"2.0.0"}\n') + + expect(repositoryGitDirty(fixtureRoot)).toBeUndefined() + expect(repositoryClientBuildEnvironment(fixtureRoot, { + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH, + DSH_CLIENT_GIT_DIRTY: 'true', + })).toEqual({ + DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), + DSH_CLIENT_VERSION: '2.0.0', + }) + }) + it('defines only public client values over a non-enumerable fallback', () => { expect(clientBuildEnvironmentDefines({ PATH: '/bin', @@ -151,6 +264,7 @@ describe('client build environment', () => { DSH_CLIENT_BUILD_PROFILE: 'official', DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7), DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.3', } const official = buildFixture(officialEnvironment) const defaultBuild = buildFixture({}) diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts index 2331db5f42..13ea20cdc1 100644 --- a/scripts/client-build-environment.ts +++ b/scripts/client-build-environment.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, globSync, @@ -25,6 +25,9 @@ const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = { /** Public variable carrying the source commit embedded in client artifacts. */ const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH' +/** Public variable carrying the repository package version embedded in client artifacts. */ +const CLIENT_VERSION_VARIABLE = 'DSH_CLIENT_VERSION' + /** Repository-relative path of the complete client build record. */ export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json' @@ -57,6 +60,76 @@ export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEn return value.slice(0, 7).toLowerCase() } +/** + * Resolve the repository package version used by browser build metadata. + * @param root - repository root containing the authoritative package.json. + * @returns the repository's semver-compatible package version. + */ +export function repositoryVersion(root: string): string { + const path = resolve(root, 'package.json') + let manifest: unknown + try { + manifest = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`cannot read repository version from ${path}: ${detail}`) + } + if (!isObject(manifest) || typeof manifest.version !== 'string' + || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) { + throw new Error(`repository package.json has an invalid version ${JSON.stringify(isObject(manifest) ? manifest.version : undefined)}`) + } + return manifest.version +} + +/** + * Read whether Git reports any staged, unstaged, untracked, or submodule change. + * @param root - repository root whose worktree is inspected. + * @returns true or false inside a Git worktree; undefined without Git metadata. + */ +export function repositoryGitDirty(root: string): boolean | undefined { + const probe = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (probe.error !== undefined || probe.status !== 0 || probe.stdout.trim() !== 'true') return undefined + + const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=normal'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (status.error !== undefined) throw status.error + if (status.status !== 0) { + throw new Error(`git status failed in ${root}: ${status.stderr.trim() || String(status.status)}`) + } + return status.stdout !== '' +} + +/** + * Resolve the public environment for a complete default build from one checkout. + * Repository-owned metadata replaces inherited values; other public values pass through. + * @param root - repository root supplying version and Git metadata. + * @param environment - caller environment supplying optional commit and public extensions. + * @returns complete public client environment for the default build. + */ +export function repositoryClientBuildEnvironment( + root: string, + environment: NodeJS.ProcessEnv = process.env, +): ClientBuildEnvironment { + const inherited = { ...clientBuildEnvironment(environment) } + delete inherited.DSH_CLIENT_COMMIT_HASH + delete inherited.DSH_CLIENT_GIT_DIRTY + delete inherited.DSH_CLIENT_VERSION + const dirty = repositoryGitDirty(root) + return { + ...inherited, + DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment), + ...(dirty === true ? { DSH_CLIENT_GIT_DIRTY: 'true' } : {}), + DSH_CLIENT_VERSION: repositoryVersion(root), + } +} + /** * Resolve the exact public values required by an official build at one commit. * @param root - repository root whose HEAD must match the built source. @@ -69,6 +142,7 @@ export function officialClientBuildEnvironment( ): Readonly> { return { DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment), + DSH_CLIENT_VERSION: repositoryVersion(root), ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT, } } @@ -115,10 +189,18 @@ export function resolveClientBuildEnvironment( if (profile === undefined) return clientBuildEnvironment(environment) if (profile === 'official') { const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE] + const version = environment[CLIENT_VERSION_VARIABLE] if (commitHash === undefined) { throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`) } - return { DSH_CLIENT_COMMIT_HASH: commitHash, ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT } + if (version === undefined) { + throw new Error(`${CLIENT_VERSION_VARIABLE} is required for the official client build profile`) + } + return { + DSH_CLIENT_COMMIT_HASH: commitHash, + DSH_CLIENT_VERSION: version, + ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT, + } } throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`) } diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index f185ee6754..4cfb15680d 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -3,7 +3,42 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, it } from 'vitest' import type { TsdownBundle } from 'tsdown' -import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts' +import { writeClientBuildRecord } from './client-build-environment.ts' +import { + devWebBuildEnvironment, + discoverLibraryDirs, + discoverPluginDirs, + watchClientPlugins, +} from './dev-web.ts' + +it('reuses the verified complete-build environment for every watcher stage', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-')) + try { + await mkdir(join(root, 'apps/web/dist'), { recursive: true }) + await mkdir(join(root, 'packages/client/example/lib'), { recursive: true }) + await writeFile(join(root, 'apps/web/dist/index.html'), '
') + await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') + writeClientBuildRecord(root, { + DSH_CLIENT_COMMIT_HASH: 'abc1234', + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: '1.2.3', + }) + + expect(devWebBuildEnvironment(root, { + PATH: '/bin', + DSH_BUILD_CLIENT_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: 'stale', + DSH_CLIENT_EXTRA: 'stale', + })).toEqual({ + PATH: '/bin', + DSH_CLIENT_COMMIT_HASH: 'abc1234', + DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_VERSION: '1.2.3', + }) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) it('discovers dsh.client packages with sibling roles', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-')) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 05f929f1b8..21775c9580 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -34,6 +34,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { execa } from 'execa' import { build } from 'tsdown' import type { TsdownBundle } from 'tsdown' +import { + CLIENT_BUILD_PROFILE_SELECTOR, + clientBuildProcessEnvironment, + readClientBuildRecord, +} from './client-build-environment.ts' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -49,6 +54,19 @@ const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend' */ const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/' +/** + * Reuse the last complete build's public metadata for every long-lived watcher. + * @param root - repository root containing the verified client build record. + * @param environment - watcher launch environment whose public values are replaced. + * @returns process environment shared by tsdown and spawned watcher stages. + */ +export function devWebBuildEnvironment( + root: string, + environment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return clientBuildProcessEnvironment(environment, readClientBuildRecord(root).environment) +} + /** * Discover the watch workspace by declaration: every packages// * whose package.json carries `dsh.client` with platform "web" is a client @@ -175,6 +193,16 @@ interface StageHandle { const invokedPath = process.argv[1] const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href if (isMain) { + const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env) + for (const name of Object.keys(process.env)) { + if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) { + Reflect.deleteProperty(process.env, name) + } + } + for (const [name, value] of Object.entries(buildEnvironment)) { + if (name.startsWith('DSH_CLIENT_') && value !== undefined) process.env[name] = value + } + const pluginDirs = discoverPluginDirs() const libraryDirs = discoverLibraryDirs() if (pluginDirs.length === 0) { From b636b01092e6756206721f663911628b07c4060d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 16:52:32 +0800 Subject: [PATCH 08/42] test: update client build fixtures --- scripts/release/families.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index d22db243d8..c3ae3c8c2f 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -29,6 +29,7 @@ function write(path: string, content: string): void { function buildFixture(environment: Record): string { const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-')) roots.push(root) + write(join(root, 'package.json'), `${JSON.stringify({ version: environment.DSH_CLIENT_VERSION ?? '0.0.1' })}\n`) write(join(root, 'apps/web/dist/index.html'), '
') write(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') writeClientBuildRecord(root, environment) @@ -106,11 +107,13 @@ describe('release families', () => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', officialEnvironment.DSH_CLIENT_COMMIT_HASH) const official = buildFixture(officialEnvironment) const defaultBuild = buildFixture({}) + const missing = join(defaultBuild, 'missing') + write(join(missing, 'package.json'), `${JSON.stringify({ version: officialEnvironment.DSH_CLIENT_VERSION })}\n`) expect(() => { dsh.verifyBuildArtifacts(official) }).not.toThrow() expect(() => { dsh.verifyBuildArtifacts(defaultBuild) }).toThrow(/DSH_CLIENT_TITLE/) - expect(() => { dsh.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/) - expect(() => { vendor.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).not.toThrow() + expect(() => { dsh.verifyBuildArtifacts(missing) }).toThrow(/record.*missing/) + expect(() => { vendor.verifyBuildArtifacts(missing) }).not.toThrow() write(join(official, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n') expect(() => { dsh.verifyBuildArtifacts(official) }).toThrow(/artifacts differ/) From 749c4ef93edb2f9ac5fdeb4b24687c5d7b36f42c Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 17:22:40 +0800 Subject: [PATCH 09/42] fix: sample dev web metadata at startup --- ...026-08-18-client-build-environment.i18n.yaml | 4 ++-- .../2026-08-18-client-build-environment.md | 8 +++++--- .../2026-08-18-client-build-environment.zh.md | 8 +++++--- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/dev-web.spec.ts | 17 ++++++++++------- scripts/dev-web.ts | 10 +++++----- 8 files changed, 31 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml index e778ec3085..7a9fa44aef 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.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/architecture/2026-08-18-client-build-environment.md -2026-08-18-client-build-environment.md: 590f53521476b5195acbad73c452fac3b2f8f914 -2026-08-18-client-build-environment.zh.md: 8803463663d3d3bd08f5abdbb6f54ed3795fe6a8 +2026-08-18-client-build-environment.md: c23a73e9520392e41949f95d04029dbf34213dd0 +2026-08-18-client-build-environment.zh.md: 5dbb5deeee75eb0f1b2cadf7e38ad8bfc5661f5c diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md index 590f535214..c23a73e952 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -20,7 +20,7 @@ The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, p The root build wrapper supplies one exact public environment to both bundlers. Every complete build carries the root package version as `DSH_CLIENT_VERSION` and the seven-character source Git HEAD prefix as `DSH_CLIENT_COMMIT_HASH`; an explicit commit supports build environments without repository metadata. A default local build also samples Git status before building and sets `DSH_CLIENT_GIT_DIRTY=true` for any staged, unstaged, untracked, or submodule change. Clean checkouts and sources without Git metadata omit the dirty field. These repository-owned fields replace inherited values, while `pnpm run build` otherwise inherits the caller's remaining `DSH_CLIENT_*` values. -`pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax. Its exact environment carries the version and commit, sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations, and omits local dirty metadata. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. `pnpm run dev:web` verifies this record before its watcher stages start and reuses its public environment for the lifetime of every stage; it does not resample repository state during incremental rebuilds. +`pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax. Its exact environment carries the version and commit, sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations, and omits local dirty metadata. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. `pnpm run dev:web` instead samples the default local environment once at startup and passes that environment to every watcher stage for the session. It does not validate the complete-build record because the watcher stages rewrite every recorded artifact. ## Alternatives considered @@ -32,10 +32,12 @@ The root build wrapper supplies one exact public environment to both bundlers. E **Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path. -**Resample Git state during every watcher rebuild.** Vite and tsdown fix define substitutions when their long-lived watchers start. Restarting the build pipeline on repository status changes would make an incremental source edit rebuild unrelated artifacts; retaining the last complete build record gives every watcher stage one consistent metadata snapshot. +**Reuse the last complete-build record for watchers.** Watcher stages rewrite every recorded client artifact, so the artifact digest becomes stale during normal development. An official-build record would also make edited local source retain the official profile and title. Startup sampling gives every stage one local metadata snapshot without coupling watcher restarts to the recorded artifact digest. + +**Resample Git state during every watcher rebuild.** Vite and tsdown fix define substitutions when their long-lived watchers start. Restarting the build pipeline on repository status changes would make an incremental source edit rebuild unrelated artifacts; sampling once at startup keeps the stages consistent without rebuilding for later status changes. ## Consequences -The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its package version and short source revision as public display metadata; a dirty default build additionally identifies its uncommitted source state. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. Watch builds retain their startup snapshot until another complete build replaces the record. +The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its package version and short source revision as public display metadata; a dirty default build additionally identifies its uncommitted source state. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. Watch builds retain their startup snapshot until `pnpm run dev:web` restarts. Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md index 8803463663..5dbb5deeee 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -20,7 +20,7 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都以 `DSH_CLIENT_VERSION` 携带根包版本,并以 `DSH_CLIENT_COMMIT_HASH` 携带源码 Git HEAD 的七位前缀;没有仓库元数据的构建环境可显式提供 commit。默认本地构建还会在构建前读取 Git 状态;存在任何暂存、未暂存、未跟踪或子模块变化时设置 `DSH_CLIENT_GIT_DIRTY=true`。没有变化的 worktree 和没有 Git 元数据的源码不携带 dirty 字段。这些由仓库持有的字段会替换继承值,除此之外,`pnpm run build` 继续继承调用方剩余的 `DSH_CLIENT_*` 值。 -`pnpm run build:official` 不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile。它的精确环境携带版本和 commit,设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用,并省略本地 dirty 元数据。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。`pnpm run dev:web` 在 watcher 启动前验证该记录,并在每个 stage 的整个生命周期内复用其中的公开环境;增量重建期间不会重新读取仓库状态。 +`pnpm run build:official` 不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile。它的精确环境携带版本和 commit,设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用,并省略本地 dirty 元数据。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。`pnpm run dev:web` 则会在启动时读取一次默认本地环境,并在本次会话中把该环境传给所有 watcher stage。它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的全部产物。 ## Alternatives considered @@ -32,10 +32,12 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define **统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。 -**每次 watcher 重建都重新读取 Git 状态。** Vite 和 tsdown 在长驻 watcher 启动时固定 define 替换。仓库状态变化时重启构建流水线,会使一次增量源码修改重建无关产物;保留上次完整构建记录可以让每个 watcher stage 共用同一份元数据快照。 +**让 watcher 复用上次完整构建记录。** watcher stage 会重写记录覆盖的全部 client 产物,因此正常开发期间产物摘要就会变为陈旧。官方构建记录还会让经过编辑的本地源码继续携带官方 profile 和标题。启动时读取一次可以让所有 stage 共用同一份本地元数据快照,同时避免 watcher 重启依赖记录的产物摘要。 + +**每次 watcher 重建都重新读取 Git 状态。** Vite 和 tsdown 在长驻 watcher 启动时固定 define 替换。仓库状态变化时重启构建流水线,会使一次增量源码修改重建无关产物;启动时只读取一次可以保持各 stage 一致,同时避免因后续状态变化而重新构建。 ## Consequences -Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的包版本和短源码 revision;dirty 的默认构建还会标明其源码存在未提交变化。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。watch build 会保留启动时的快照,直到另一次完整构建替换该记录。 +Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的包版本和短源码 revision;dirty 的默认构建还会标明其源码存在未提交变化。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。watch build 会保留启动时的快照,直到 `pnpm run dev:web` 重启。 任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 890c386afe..07b306bede 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: 16a2242d5d1b5d91718c6568b351aceb7439f38d -development.zh.md: 0db020bf66f1c16fbc6c987e60f5b61bf052cf62 +development.md: d37be2802004a044ea3516506bbbb51305c622de +development.zh.md: 3a661c3356a77f6c8c85316e74cf14a97fd90489 diff --git a/docs/development.md b/docs/development.md index 16a2242d5d..d37be28020 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing, development watchers, and built Web tests reject a missing record or artifacts changed by a later partial build. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. diff --git a/docs/development.zh.md b/docs/development.zh.md index 0db020bf66..3a661c3356 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -79,7 +79,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包、开发 watcher 和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 4cfb15680d..76dbf82860 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -11,28 +11,31 @@ import { watchClientPlugins, } from './dev-web.ts' -it('reuses the verified complete-build environment for every watcher stage', async () => { +it('samples one local environment at startup without validating watcher outputs', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-')) try { await mkdir(join(root, 'apps/web/dist'), { recursive: true }) await mkdir(join(root, 'packages/client/example/lib'), { recursive: true }) + await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.2.3' })) await writeFile(join(root, 'apps/web/dist/index.html'), '
') await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n') writeClientBuildRecord(root, { - DSH_CLIENT_COMMIT_HASH: 'abc1234', - DSH_CLIENT_GIT_DIRTY: 'true', - DSH_CLIENT_VERSION: '1.2.3', + DSH_CLIENT_BUILD_PROFILE: 'official', + DSH_CLIENT_COMMIT_HASH: 'fffffff', + DSH_CLIENT_TITLE: 'DeepSeek Harness', + DSH_CLIENT_VERSION: '1.2.2', }) + await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n') expect(devWebBuildEnvironment(root, { PATH: '/bin', DSH_BUILD_CLIENT_PROFILE: 'official', - DSH_CLIENT_COMMIT_HASH: 'stale', - DSH_CLIENT_EXTRA: 'stale', + DSH_CLIENT_COMMIT_HASH: 'abc1234', + DSH_CLIENT_EXTRA: 'launch-value', })).toEqual({ PATH: '/bin', DSH_CLIENT_COMMIT_HASH: 'abc1234', - DSH_CLIENT_GIT_DIRTY: 'true', + DSH_CLIENT_EXTRA: 'launch-value', DSH_CLIENT_VERSION: '1.2.3', }) } finally { diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 21775c9580..35500eca13 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -37,7 +37,7 @@ import type { TsdownBundle } from 'tsdown' import { CLIENT_BUILD_PROFILE_SELECTOR, clientBuildProcessEnvironment, - readClientBuildRecord, + repositoryClientBuildEnvironment, } from './client-build-environment.ts' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -55,16 +55,16 @@ const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend' const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/' /** - * Reuse the last complete build's public metadata for every long-lived watcher. - * @param root - repository root containing the verified client build record. - * @param environment - watcher launch environment whose public values are replaced. + * Sample one local public environment for every long-lived watcher stage. + * @param root - repository root supplying version and Git metadata. + * @param environment - watcher launch environment supplying public extensions. * @returns process environment shared by tsdown and spawned watcher stages. */ export function devWebBuildEnvironment( root: string, environment: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - return clientBuildProcessEnvironment(environment, readClientBuildRecord(root).environment) + return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment)) } /** From 17bde3f5bea240c152fcddd51340a48adb1b9f22 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 21 Aug 2026 18:00:10 +0800 Subject: [PATCH 10/42] feat: label local build banner --- apps/web/tests/built-boot.snapshot.ts | 1 + packages/client/ui-sidebar/README.i18n.yaml | 4 +-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 26 +++++++++++++++++++ .../ui-sidebar/src/client/SidebarRoot.tsx | 21 ++++++++------- .../sidebar-snapshot.client.spec.tsx.snap | 26 ++++++++++++++++--- .../tests/sidebar-root.client.spec.tsx | 2 ++ 8 files changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 3a688bbe5c..6999543b00 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -73,6 +73,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn + (commit === undefined ? '' : `-${commit}`) + (clientBuildValue('DSH_CLIENT_GIT_DIRTY') === 'true' ? '-dirty' : '') screen.getByText('DSH') + screen.getByText('Local Build') screen.getByText(buildVersion) } // The compact layout dropped group session counts; the fixture workspace diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index d33c1ce612..2a3119689c 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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/client/ui-sidebar/README.md -README.md: 8a7963f1cd5cd0cf9e1d951e68aa53a35f0c4c3c -README.zh.md: 4c9040589ab8a28897821a2e476786ccf8ace50c +README.md: 406f2930ac1deb9bf8e12caedbcfb11f7fc35fbc +README.zh.md: 147c30b5de5db07bbffa665157c3707430c0a099 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 8a7963f1cd..406f2930ac 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. +The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` with a small `Local Build` caption plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 4c9040589a..147c30b5de 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`,并把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 及 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `Local Build` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index fc2ec94aaa..d280ab647f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -159,6 +159,30 @@ white-space: nowrap; } +.localBuildBrand { + flex: none; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + height: 24px; + white-space: nowrap; +} + +.localBuildName { + font-size: 16px; + line-height: 15px; + letter-spacing: 0; +} + +.localBuildLabel { + color: var(--dsw-alias-label-secondary); + font-size: 7px; + font-weight: 500; + line-height: 8px; + letter-spacing: 0.02em; +} + .iconButton { flex: none; display: inline-flex; @@ -211,6 +235,7 @@ } .buildVersion { + flex: none; display: inline-flex; align-items: center; height: 16px; @@ -222,6 +247,7 @@ font-size: 8px; font-weight: 500; line-height: 16px; + white-space: nowrap; } /* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 3fb2a68fd9..bdc5140b24 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -153,16 +153,17 @@ export function SidebarRoot({ {renderSlot('sidebar.brand.name', {}, { - fallback: ( - <> - - {buildVersion === undefined ? 'DSH Local Build' : 'DSH'} - - {buildVersion === undefined - ? null - : {buildVersion}} - - ), + fallback: buildVersion === undefined + ? DSH Local Build + : ( + <> + + DSH + Local Build + + {buildVersion} + + ), })} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index 7780754125..929ce9d72a 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -138,9 +138,18 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul style="display: contents;" > - DSH + + DSH + + + Local Build + renders the expanded column in the default lo style="display: contents;" > - DSH + + DSH + + + Local Build + { />) expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText('Local Build')).toBeTruthy() expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy() expect(container.querySelector('svg')).not.toBeNull() }) @@ -129,6 +130,7 @@ describe('SidebarRoot shell', () => { />) expect(screen.getByText('DSH')).toBeTruthy() + expect(screen.getByText('Local Build')).toBeTruthy() expect(screen.getByText(expected)).toBeTruthy() }) From 720c5c247cc9ff3755a6e627e5efb39bd2d6f243 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 24 Aug 2026 11:48:38 +0800 Subject: [PATCH 11/42] fix(client): localize local build banner --- packages/client/locale/src/locales/en.ts | 2 ++ packages/client/locale/src/locales/zh.ts | 2 ++ packages/client/ui-sidebar/README.i18n.yaml | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- packages/client/ui-sidebar/src/client/SidebarRoot.tsx | 6 +++--- .../__snapshots__/sidebar-snapshot.client.spec.tsx.snap | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index bb4347c085..cd590c78b1 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -30,7 +30,9 @@ export const en = { 'collapse': 'Collapse', 'expand': 'Expand', 'back': 'Back', + 'brand.name': 'DSH', 'brand.localBuild': 'DSH Local Build', + 'brand.localBuildLabel': 'Local Build', 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index d5b9a45cfd..1d25d446c1 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -28,7 +28,9 @@ export const zh = { 'collapse': '收起', 'expand': '展开', 'back': '返回', + 'brand.name': 'DSH', 'brand.localBuild': 'DSH 本地构建', + 'brand.localBuildLabel': '本地构建', 'unknown': '未知', 'none': '无', 'truncated': '已截断', diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 2a3119689c..e2be13acb2 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md README.md: 406f2930ac1deb9bf8e12caedbcfb11f7fc35fbc -README.zh.md: 147c30b5de5db07bbffa665157c3707430c0a099 +README.zh.md: fd809b20298d069b37076c8dde4fe98f8fb761c8 diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 147c30b5de..fd809b2029 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `Local Build` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `本地构建` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH 本地构建` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index bdc5140b24..fc6987d5ba 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -154,12 +154,12 @@ export function SidebarRoot({ {renderSlot('sidebar.brand.name', {}, { fallback: buildVersion === undefined - ? DSH Local Build + ? {t('brand.localBuild')} : ( <> - DSH - Local Build + {t('brand.name')} + {t('brand.localBuildLabel')} {buildVersion} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index 929ce9d72a..dd0d957272 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -283,7 +283,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo - Local Build + 本地构建 Date: Mon, 24 Aug 2026 15:03:49 +0800 Subject: [PATCH 12/42] style(client): stack local build metadata --- apps/web/tests/built-boot.snapshot.ts | 3 +-- packages/client/locale/src/locales/en.ts | 2 -- packages/client/locale/src/locales/zh.ts | 2 -- packages/client/ui-sidebar/README.i18n.yaml | 4 +-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- .../src/client/SidebarRoot.module.css | 25 +++++++----------- .../ui-sidebar/src/client/SidebarRoot.tsx | 9 +++---- .../sidebar-snapshot.client.spec.tsx.snap | 26 ++++++------------- .../tests/sidebar-root.client.spec.tsx | 6 ++--- 10 files changed, 27 insertions(+), 54 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 6999543b00..cbd5f294a4 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -72,8 +72,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const buildVersion = version + (commit === undefined ? '' : `-${commit}`) + (clientBuildValue('DSH_CLIENT_GIT_DIRTY') === 'true' ? '-dirty' : '') - screen.getByText('DSH') - screen.getByText('Local Build') + screen.getByText('DSH Local Build') screen.getByText(buildVersion) } // The compact layout dropped group session counts; the fixture workspace diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index cd590c78b1..bb4347c085 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -30,9 +30,7 @@ export const en = { 'collapse': 'Collapse', 'expand': 'Expand', 'back': 'Back', - 'brand.name': 'DSH', 'brand.localBuild': 'DSH Local Build', - 'brand.localBuildLabel': 'Local Build', 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 1d25d446c1..d5b9a45cfd 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -28,9 +28,7 @@ export const zh = { 'collapse': '收起', 'expand': '展开', 'back': '返回', - 'brand.name': 'DSH', 'brand.localBuild': 'DSH 本地构建', - 'brand.localBuildLabel': '本地构建', 'unknown': '未知', 'none': '无', 'truncated': '已截断', diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index e2be13acb2..fe0b552cee 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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/client/ui-sidebar/README.md -README.md: 406f2930ac1deb9bf8e12caedbcfb11f7fc35fbc -README.zh.md: fd809b20298d069b37076c8dde4fe98f8fb761c8 +README.md: 075a132b9fc35ec4aee871690b436d3380e616fb +README.zh.md: 39ec5d58cd7270fbefdaa81eb0a6a022229d7963 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 406f2930ac..075a132b9f 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a local-build fallback: a complete build renders `DSH` with a small `Local Build` caption plus a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata retains the defensive `DSH Local Build` label. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. +The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks below it a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar. New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index fd809b2029..39ec5d58cd 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -4,7 +4,7 @@ 侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。 -展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地构建 fallback:完整构建显示 `DSH`、小号 `本地构建` 标注,以及把 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成的 `version[-commit][-dirty]` 代码徽标;缺少版本元数据时保留防御性的 `DSH 本地构建` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 +展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标由 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。 New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index d280ab647f..d472581e43 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -165,24 +165,17 @@ flex-direction: column; align-items: flex-start; justify-content: center; + gap: 1px; height: 24px; white-space: nowrap; } -.localBuildName { - font-size: 16px; - line-height: 15px; +.localBuildTitle { + font-size: 12px; + line-height: 13px; letter-spacing: 0; } -.localBuildLabel { - color: var(--dsw-alias-label-secondary); - font-size: 7px; - font-weight: 500; - line-height: 8px; - letter-spacing: 0.02em; -} - .iconButton { flex: none; display: inline-flex; @@ -238,15 +231,15 @@ flex: none; display: inline-flex; align-items: center; - height: 16px; - padding: 0 4px; - border-radius: 3px; + height: 10px; + padding: 0 3px; + border-radius: 2px; color: var(--dsw-alias-label-primary-inverted); background: var(--dsw-alias-label-primary); font-family: var(--ds-font-family-code); - font-size: 8px; + font-size: 6px; font-weight: 500; - line-height: 16px; + line-height: 10px; white-space: nowrap; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index fc6987d5ba..9dde97bf9e 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -156,13 +156,10 @@ export function SidebarRoot({ fallback: buildVersion === undefined ? {t('brand.localBuild')} : ( - <> - - {t('brand.name')} - {t('brand.localBuildLabel')} - + + {t('brand.localBuild')} {buildVersion} - + ), })} diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index dd0d957272..ba3e4d9540 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -141,21 +141,16 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul class="localBuildBrand" > - DSH + DSH Local Build - Local Build + 1.2.3-rc.4-abc1234-dirty - - 1.2.3-rc.4-abc1234-dirty - @@ -276,21 +271,16 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo class="localBuildBrand" > - DSH + DSH 本地构建 - 本地构建 + 1.2.3-rc.4-abc1234-dirty - - 1.2.3-rc.4-abc1234-dirty - diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 63812450a9..0f5edce0de 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -110,8 +110,7 @@ describe('SidebarRoot shell', () => { options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) - expect(screen.getByText('DSH')).toBeTruthy() - expect(screen.getByText('Local Build')).toBeTruthy() + expect(screen.getByText('DSH Local Build')).toBeTruthy() expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy() expect(container.querySelector('svg')).not.toBeNull() }) @@ -129,8 +128,7 @@ describe('SidebarRoot shell', () => { options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) - expect(screen.getByText('DSH')).toBeTruthy() - expect(screen.getByText('Local Build')).toBeTruthy() + expect(screen.getByText('DSH Local Build')).toBeTruthy() expect(screen.getByText(expected)).toBeTruthy() }) From 15ddb2edc42d413d708a74214e193c397e14de8f Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 24 Aug 2026 15:28:14 +0800 Subject: [PATCH 13/42] test(web): wait for stable preview onboarding --- apps/web/tests/preview-boot.e2e.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 8ef63e1067..dd8daa9dad 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -302,12 +302,10 @@ async function bootPreview(origin: string, browser: Browser): Promise { // report the older one. expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`) expect(bootLine).toContain('data overlays=1') - // The hero's workspace picker is the client tree's first interactive - // surface, so it appears only once the startup chain completed over the - // tunnel. - await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS }) + // The versioned notice is the seeded preview's first stable interactive + // surface after the startup chain completes over the tunnel. const continueButton = page.getByRole('button', { name: 'Continue' }) - await continueButton.waitFor({ timeout: 30_000 }) + await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS }) await continueButton.click() const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) From 5c799a95201ad52ea2ea1fc05a2ccbb5b7cadc19 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 15:44:42 +0800 Subject: [PATCH 14/42] =?UTF-8?q?fix(attachment):=20=E4=BF=AE=E6=AD=A3=20W?= =?UTF-8?q?indows=20=E5=8F=AA=E8=AF=BB=E5=8F=91=E5=B8=83=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 46 ++++++++++--------- docs/module-graph.zh.md | 46 ++++++++++--------- .../attachment/attachment-local/src/store.ts | 8 ++-- .../attachment-local/tests/store.spec.ts | 8 ++++ 5 files changed, 63 insertions(+), 49 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index b8a9b43bd1..8ba88343ec 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: d70aa9a7704a7de5b669928a6cafd8358fb2a3b0 -module-graph.zh.md: 2333d71e61bd935fa482fc766bb7d96bb75d56db +module-graph.md: aeb35195f3a095b9de694f164dc1111acf5c8197 +module-graph.zh.md: ef3bc3fbce9cad37542eeea6adf936dd70e6581b diff --git a/docs/module-graph.md b/docs/module-graph.md index d70aa9a770..aeb35195f3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -439,18 +439,6 @@ flowchart TD pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_brand - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -470,14 +458,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_llm_pi_ai --> pkg_attachment - pkg_llm_pi_ai --> pkg_authorization - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_launch_environment - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -653,6 +633,28 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_authorization + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_fs + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_launch_environment + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions @@ -1696,14 +1698,12 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1745,6 +1745,8 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2333d71e61..ef3bc3fbce 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -441,18 +441,6 @@ flowchart TD pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings - pkg_llm_deepseek --> pkg_anonymous_user_id - pkg_llm_deepseek --> pkg_atomic_write - pkg_llm_deepseek --> pkg_attachment - pkg_llm_deepseek --> pkg_brand - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions - pkg_llm_deepseek --> pkg_home_paths - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_launch_environment - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -472,14 +460,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_llm_pi_ai --> pkg_attachment - pkg_llm_pi_ai --> pkg_authorization - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_launch_environment - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope @@ -655,6 +635,28 @@ flowchart TD pkg_workspace --> pkg_session_persistence pkg_workspace --> pkg_storage pkg_workspace --> pkg_storage_domain + pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_atomic_write + pkg_llm_deepseek --> pkg_attachment + pkg_llm_deepseek --> pkg_brand + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_deepseek_llm_api_extensions + pkg_llm_deepseek --> pkg_fs + pkg_llm_deepseek --> pkg_home_paths + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_launch_environment + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_authorization + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_fs + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_launch_environment + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout pkg_plugin_package_inventory_deepseek --> pkg_agent pkg_plugin_package_inventory_deepseek --> pkg_agent_presets pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions @@ -1698,14 +1700,12 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1747,6 +1747,8 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index f5463a478f..e5a979aec1 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -222,8 +222,11 @@ export async function commitPreparedImageFile( const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } - // The hard link and staging entry share one inode, so this applies to both - // names until cleanup and also enforces read-only mode on deduplicated objects. + // Windows shares the read-only attribute across hard links and refuses to + // unlink either name once it is set, so discard the staging name first. + await unlink(temporary) + // The target remains the sole link for a new object; this also restores + // read-only mode when the deduplication path observes an existing object. await chmod(target, 0o400) // Persist the target entry and close a concurrent bucket-creation window // before the reference can reach a session checkpoint. The dedup path @@ -231,7 +234,6 @@ export async function commitPreparedImageFile( // that writer reaches its own durability boundary. await syncDirectory(bucket) await syncDirectory(join(root, 'objects')) - await unlink(temporary) } catch (error) { /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ if (handle !== undefined) await handle.close().catch( diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 64cca74b7e..478c3f6d50 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -141,6 +141,14 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it.skipIf(process.platform !== 'win32')('publishes a new object on Windows', async () => { + const storageRoot = await root() + + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + it('stores the normalized image of an oversized source and reads it back verified', async () => { const storageRoot = await root() const oversized = new Uint8Array(await sharp({ From 7bad88206b26b9360abe51739b38f89756805b7d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 16:01:49 +0800 Subject: [PATCH 15/42] =?UTF-8?q?test(llm):=20=E8=A6=86=E7=9B=96=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E7=8E=AF=E5=A2=83=E5=9B=BE=E7=89=87=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../llm-deepseek/tests/dynamic-config.spec.ts | 20 ++++++++++++++++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 22 +++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index fd6759c152..75cb90000b 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -30,6 +30,18 @@ const IMAGE_REF: ImageAttachmentRef = { width: 1, height: 1, } +const HOST_IMAGE_PATH = '/host/.dsh/attachments/objects/aa/object' +const MODEL_IMAGE_PATH = '/model/.dsh/attachments/objects/aa/object' + +class MappedFileSystem extends Service { + constructor(ctx: Context) { + super(ctx, 'fs') + } + + processPathFromHostPath(hostPath: string): string | undefined { + return hostPath === HOST_IMAGE_PATH ? MODEL_IMAGE_PATH : undefined + } +} class StaticAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { @@ -53,6 +65,10 @@ class StaticAttachmentStore extends AttachmentStore { return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) } + override imageHostPath(_ref: ImageAttachmentRef): string { + return HOST_IMAGE_PATH + } + override readImageRequest( ref: ImageAttachmentRef, _policy: ImageRequestPolicy, @@ -193,6 +209,7 @@ describe('request-level dynamic configuration', () => { { kind: 'sse', events: textEvents }, ]) const { ctx } = await boot(dir, { baseURL: server.url }) + await ctx.plugin(MappedFileSystem) const messages = [createUserMessage({ content: [ { type: 'image', attachment: IMAGE_REF }, @@ -209,6 +226,7 @@ describe('request-level dynamic configuration', () => { const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') + expect(JSON.stringify(second)).toContain(MODEL_IMAGE_PATH) expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) }) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index b86f1d93ca..21d5b2c486 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, @@ -31,6 +31,18 @@ const IMAGE_REF: ImageAttachmentRef = { width: 1, height: 1, } +const HOST_IMAGE_PATH = '/host/.dsh/attachments/objects/aa/object' +const MODEL_IMAGE_PATH = '/model/.dsh/attachments/objects/aa/object' + +class MappedFileSystem extends Service { + constructor(ctx: Context) { + super(ctx, 'fs') + } + + processPathFromHostPath(hostPath: string): string | undefined { + return hostPath === HOST_IMAGE_PATH ? MODEL_IMAGE_PATH : undefined + } +} async function harness(baseURL: string, overrides: Record = {}): Promise { vi.stubEnv('PI_TEST_KEY', 'test-key') @@ -228,7 +240,7 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) - it('resolves an attachment service mounted after the adapter when dispatching an image', async () => { + it('resolves attachment and filesystem services mounted after the adapter when dispatching an image', async () => { const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) const ref: ImageAttachmentRef = { @@ -281,6 +293,10 @@ describe('PiAiAdapter provider routing', () => { return readImage(value) } + override imageHostPath(_ref: ImageAttachmentRef): string { + return HOST_IMAGE_PATH + } + override readImageRequest( value: ImageAttachmentRef, policy: ImageRequestPolicy, @@ -296,6 +312,7 @@ describe('PiAiAdapter provider routing', () => { providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, }) await ctx.plugin(LateAttachmentStore) + await ctx.plugin(MappedFileSystem) const result = await assemble(ctx, { provider: 'openai', @@ -311,6 +328,7 @@ describe('PiAiAdapter provider routing', () => { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024, }, expect.any(AbortSignal)) + expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH) expect(server.paths).toEqual(['/v1/responses']) }) From d61ba08685d132c1a1fbab7f4fc2844f3e60eca4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 24 Aug 2026 16:04:17 +0800 Subject: [PATCH 16/42] fix(client): restore branded running copy --- packages/client/ui-chat/src/client/locale.ts | 2 +- packages/client/ui-chat/tests/chat-view.client.spec.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9f6a18fd7b..be8a3521ac 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -27,7 +27,7 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', - 'chat.deepDiving': '正在深入处理…', + 'chat.deepDiving': '深度求索中...', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index d6ccc631e6..d112bd779c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -914,7 +914,7 @@ describe('ChatView', () => { const view = render() expect(view.getByTestId('tool-seat-r1')).toBeTruthy() expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' }) - expect(view.getByRole('status').textContent).toBe('正在深入处理…') + expect(view.getByRole('status').textContent).toBe('深度求索中...') }) it('keeps the Tool renderer mounted when a running call settles into log order', () => { @@ -974,7 +974,7 @@ describe('ChatView', () => { const view = render() // Freshly mounted (as after a reload) yet already past the 15s gate. const status = view.getByRole('status') - expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/) + expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() act(() => { h.setSession({ queue: [{ @@ -986,7 +986,7 @@ describe('ChatView', () => { text: 'also', }] }) }) - expect(status.textContent).toMatch(/^正在深入处理…2分0\d秒$/) + expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) }) it('hands each ordered root call to the keyed business-node slot', () => { From 486389053518878ad4a89925ee7ba13075cc9dd1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 16:14:32 +0800 Subject: [PATCH 17/42] fix(attachment): route image encoding by alpha over shared quality ladders Delete the 5-bit colour-count classifier and palette PNG branch that misrouted high-frequency photographic JPEGs (issue #2885 images 23/24) into an encoder 100x slower with 4x larger output. Both normalization and request-image encoding now route by the decoded alpha fact alone: opaque sources down a JPEG ladder and alpha sources down a WebP effort-0 ladder, each at qualities 85/75/60. Byte budgets become ladder targets: the downscale retry loop is gone and a ladder-exhausted encode keeps its smallest output, while provider byte caps stay enforced at the transmitting route. Request transforms move to request-image-v5. --- ...pha-routed-image-quality-ladders.i18n.yaml | 6 + ...8-24-alpha-routed-image-quality-ladders.md | 31 +++++ ...4-alpha-routed-image-quality-ladders.zh.md | 31 +++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 +- docs/config-catalog.zh.md | 12 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 4 +- .../attachment/attachment-local/README.zh.md | 4 +- .../attachment/attachment-local/src/index.ts | 7 +- .../attachment-local/src/normalization.ts | 100 ++++---------- .../attachment-local/src/request-image.ts | 62 +++------ .../attachment-local/tests/index.spec.ts | 6 +- .../tests/normalization-verification.spec.ts | 38 ++++++ .../tests/normalization.spec.ts | 122 +++--------------- .../tests/request-image.spec.ts | 16 ++- .../attachment-local/tests/store.spec.ts | 2 +- packages/attachment/attachment/src/types.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/src/config.ts | 5 +- 26 files changed, 233 insertions(+), 255 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md create mode 100644 packages/attachment/attachment-local/tests/normalization-verification.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml new file mode 100644 index 0000000000..344499741c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md +2026-08-24-alpha-routed-image-quality-ladders.md: 16b3213268999d846f0b2a5ab63b3f5ae4adc332 +2026-08-24-alpha-routed-image-quality-ladders.zh.md: dde9ba60996cdfb779a6a1a88810fec608bc0609 diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md new file mode 100644 index 0000000000..16b3213268 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md @@ -0,0 +1,31 @@ +# Agent Note: Alpha-routed image quality ladders replace colour-count codec routing + +Status: implemented + +English | [中文](2026-08-24-alpha-routed-image-quality-ladders.zh.md) + +## Problem + +Image normalization and request-image encoding in `@deepseek-ai/dsh-attachment-local` chose their codec by a 5-bit colour-count sample: images whose 128×128 nearest-neighbour sample stayed within 256 quantized colours went to palette PNG (libimagequant) before WebP, other alpha images to WebP, and other opaque images to JPEG. High-frequency photographic JPEGs routinely quantize below the threshold — issue #2885's 8000×8000 reproduction images measure 175 and 184 sampled colours against 2145 and 4077 real colours — and palette PNG is the slowest encoder in the pipeline while producing files about four times larger than JPEG on such content (measured 2657ms/3.95MiB versus 26ms/0.95MiB at the 2048px master size). The sample itself forces a full decode (`fastShrinkOnLoad: false`), costing 86 to 192ms on 64MP sources for every image. When every candidate exceeded the byte cap, both encoders also entered a proportional-downscale retry loop ending in an `IMAGE_TOO_LARGE` error, although measured worst-case inputs (uniform noise) fit the default budgets at the first quality. + +## Decision + +Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` in `normalization.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id. + +Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices) back the choice: on photographic content JPEG is one to two orders of magnitude faster than every alternative, and WebP at effort 0 matches palette PNG's size on graphics content while never being misrouted; uniform-noise worst cases fit the default 4MiB/1MiB targets at quality 85 for opaque sources, and only an adversarial random-alpha plane exhausts the WebP ladder (about 6.3MiB, five times under the provider cap). + +## Alternatives considered + +**Repair the classifier (higher-resolution sampling, gradient statistics) and keep palette PNG.** Rejected: any content classifier retains a misrouting class and the per-image classification decode; palette PNG's only frontier niche (graphics) is matched by WebP at a fraction of the encode time. + +**A single WebP ladder for everything.** Rejected in review discussion: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing. + +**Keep the downscale retry loop for ladder-exhausted outputs.** Rejected: measured worst cases show the loop is dead code within default budgets, and its only reachable effect was degrading adversarial inputs to 1×1 before erroring. + +## Consequences + +- Opaque low-colour graphics (charts, text screenshots) now store as JPEG: two to three times larger than palette PNG in the hundreds-of-kilobytes range, with JPEG ringing on hard edges; the model-visible request version was already dominated by pixel-budget downscaling, so legibility impact is marginal. Reintroducing a graphics codec would add a WebP step to the opaque ladder, not restore classification. +- GIF sources decode with an alpha plane under gifload, so still-frame GIFs normalize onto the WebP ladder. +- `IMAGE_TOO_LARGE` no longer arises from encoding; it remains the admission error for oversized sources. +- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input. +- Test evidence: `packages/attachment/attachment-local/tests` pins the routing, ladder-exhaustion, and readable-text behavior against real encoders, including the issue #2885 misrouting characteristics (high-frequency photographic content leaving the slow path). diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md new file mode 100644 index 0000000000..dde9ba6099 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 按 alpha 路由的图片质量阶梯取代按色数分类的编码路由 + +Status: implemented + +[English](2026-08-24-alpha-routed-image-quality-ladders.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-attachment-local` 的图片规范化和请求版本编码此前按 5-bit 色数采样选择编码器:128×128 最近邻采样量化后不超过 256 色的图片先走 palette PNG(libimagequant)再退 WebP,其他透明图片走 WebP,其他不透明图片走 JPEG。高频摄影类 JPEG 的量化采样经常落在阈值以下,issue #2885 的 8000×8000 复现图片采样色数为 175 和 184,实际色数为 2145 和 4077,而 palette PNG 是管线里最慢的编码器,在这类内容上产物还比 JPEG 大约 4 倍(2048px master 尺寸实测 2657ms/3.95MiB 对 26ms/0.95MiB)。采样本身因 `fastShrinkOnLoad: false` 必须全尺寸解码,64MP 源图上每张都要付出 86 至 192ms。当所有候选都超过字节上限时,两个编码器还会进入按比例缩图重试的循环,最终以 `IMAGE_TOO_LARGE` 报错,而实测最坏输入(均匀噪声)在默认预算下第一档质量就能装下。 + +## 决定 + +两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`normalization.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。 + +对 issue #2885 复现集的 Pareto 实测(PR #2989 附录)支撑这个选择:摄影类内容上 JPEG 比其余所有编码器快 1 至 2 个数量级,effort 0 的 WebP 在图形类内容上体积与 palette PNG 相当且不会被误判;均匀噪声最坏输入在不透明链的 q85 一档即落入默认 4MiB/1MiB 目标,只有对抗性的随机 alpha 平面会耗尽 WebP 阶梯(约 6.3MiB,距提供方上限还有 5 倍)。 + +## 考虑过的替代方案 + +**修复分类器(提高采样分辨率、加入梯度统计)并保留 palette PNG。** 否决:任何内容分类器都保留一类误判和每张图的分类解码成本;palette PNG 唯一的前沿生态位(图形类)WebP 用远少的编码时间即可达到。 + +**全部走单一 WebP 阶梯。** 在评审讨论中否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。 + +**为阶梯耗尽的产物保留缩图重试循环。** 否决:实测最坏情况表明该循环在默认预算内是死代码,其唯一可达效果是把对抗性输入一路缩到 1×1 再报错。 + +## 后果 + +- 不透明的低色数图形(图表、文字截图)现在存为 JPEG:在几百 KB 量级上比 palette PNG 大 2 至 3 倍,锐利边缘有 JPEG 振铃;模型可见的请求版本本就被像素预算缩尺寸主导,可读性影响很小。将来若需要图形类专用编码,正确做法是给不透明阶梯加一档 WebP,而不是恢复分类。 +- GIF 源图经 gifload 解码后带 alpha 平面,因此静帧 GIF 规范化走 WebP 阶梯。 +- `IMAGE_TOO_LARGE` 不再产生于编码环节;它仍是超大源图的准入错误。 +- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。 +- 测试证据:`packages/attachment/attachment-local/tests` 用真实编码器钉住路由、阶梯耗尽和文字可读性行为,包括 issue #2885 误判特征(高频摄影内容离开慢路径)。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index aed3574106..961b038e2d 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: a845fe22e13ed085765668c7ec8d54d6bbdf129a -config-catalog.zh.md: 39ba9d48368f99483733292f997609ba3a8aa43e +config-catalog.md: 1fdc23a72f8cf1af49c5b02c11c6861fad1c16af +config-catalog.zh.md: e59c647462711504ba1f420a73813db5e921f334 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a845fe22e1..1fdc23a72f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -310,7 +310,10 @@ export interface Config { maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent normalized image. */ normalizedImageMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + /** + * Encoded-byte target of the stored provider-independent normalized image; + * the smallest quality-ladder output is kept when no quality fits. + */ normalizedImageMaxBytes?: number /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number @@ -943,7 +946,7 @@ export interface DeepSeekCatalogModel { inputModalities?: ModelModality[] /** Total-pixel budget for one deterministic request preview. */ imagePixelBudget?: number - /** Encoded-byte cap for one deterministic request preview. */ + /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ imageDetail?: 'auto' | 'low' @@ -1056,7 +1059,10 @@ export interface PiAiProviderProfile { maxRequestImageBytes?: number /** Total-pixel budget for each deterministic inline request version. */ requestImagePixelBudget?: number - /** Raw encoded-byte cap for each deterministic inline request version. */ + /** + * Raw encoded-byte target for each deterministic inline request version; + * the smallest quality-ladder output is used when no quality fits. + */ requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig @@ -1215,7 +1221,7 @@ export type PiAiThinkingFormat = NonNullable diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 39ba9d4836..e59c647462 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -312,7 +312,10 @@ export interface Config { maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent normalized image. */ normalizedImageMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + /** + * Encoded-byte target of the stored provider-independent normalized image; + * the smallest quality-ladder output is kept when no quality fits. + */ normalizedImageMaxBytes?: number /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number @@ -945,7 +948,7 @@ export interface DeepSeekCatalogModel { inputModalities?: ModelModality[] /** Total-pixel budget for one deterministic request preview. */ imagePixelBudget?: number - /** Encoded-byte cap for one deterministic request preview. */ + /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ imageDetail?: 'auto' | 'low' @@ -1058,7 +1061,10 @@ export interface PiAiProviderProfile { maxRequestImageBytes?: number /** Total-pixel budget for each deterministic inline request version. */ requestImagePixelBudget?: number - /** Raw encoded-byte cap for each deterministic inline request version. */ + /** + * Raw encoded-byte target for each deterministic inline request version; + * the smallest quality-ladder output is used when no quality fits. + */ requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index b93c9ef1ca..5bbdecb070 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: e6d0a53db2827a38a1535380319b6220aa37f0a4 -attachment.zh.md: 8328ec610d4d68624f75f00d6a397b13fdf31c4e +attachment.md: 1a8c01ca88f6cf5f252838e3a269f5465dcfcf2a +attachment.zh.md: 97f3bb2d62b60b63484f3e612fd175b9649bbcdc diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index e6d0a53db2..1a8c01ca88 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -98,7 +98,7 @@ interface StoredImageAttachment { interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ maxPixels: number - /** Encoded-byte cap before base64 expansion or Files API upload. */ + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } ``` diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 8328ec610d..97f3bb2d62 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -98,7 +98,7 @@ interface StoredImageAttachment { interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ maxPixels: number - /** Encoded-byte cap before base64 expansion or Files API upload. */ + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } ``` diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 3698abdcb2..545e201707 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: 3ed4ab3251b0a609807c76930226bec63f0164cd -README.zh.md: 85abd10389acc46c2d89dd85628f5d201b089710 +README.md: 5f7447aad383a3b5c5d08bb78351471b61a3fc2b +README.zh.md: b58e352bc0401dc486181788c959d70b3c52a1a7 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 3ed4ab3251..5f7447aad3 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` safety cap (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color images try PNG, using a palette only when the input has no alpha channel, then WebP at qualities 85, 80, and 75. Other alpha images try WebP at those qualities; other opaque images try JPEG. Each candidate runs only after the preceding candidate exceeds the cap. Dimensions shrink only after every candidate at one size exceeds the cap. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then enforces a separate encoded-byte cap. The request encoder uses the same color branches, with PNG (palette only without alpha) before WebP 85 and 80 for low-color images, WebP 85 then 80 for other alpha images, and JPEG 85 then 80 for other opaque images. It executes candidates lazily and reduces dimensions only after both quality attempts exceed the request cap. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 85abd10389..b58e352bc0 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,9 +4,9 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 安全上限(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。系统用 nearest-neighbour 对有界样本分类,不会通过像素平均把高频图片误判为低色数。确认的低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明图片依次尝试这些质量的 WebP;其他非透明图片依次尝试这些质量的 JPEG。只有前一个候选超限时才会执行下一个候选;同一尺寸的候选全部超限后才缩小尺寸。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 -请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再执行独立的编码字节上限。请求编码器使用同一分类分支:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。候选按需执行,两个质量档均超限后才缩小尺寸。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index e9a1145ba5..f615bbb187 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -40,7 +40,7 @@ export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 * every later model request without refusing ordinary large sources. */ export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 2048 -/** Default independent safety cap for one stored normalized image. */ +/** Default encoded-byte target for one stored normalized image. */ export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024 /** Conservative default number of simultaneous native image transformations per store. */ export const DEFAULT_IMAGE_COMPRESSION_CONCURRENCY = 2 @@ -63,7 +63,10 @@ export interface Config { maxImageDimension?: number /** Long-edge pixel cap of the stored provider-independent normalized image. */ normalizedImageMaxDimension?: number - /** Encoded-byte safety cap of the stored provider-independent normalized image. */ + /** + * Encoded-byte target of the stored provider-independent normalized image; + * the smallest quality-ladder output is kept when no quality fits. + */ normalizedImageMaxBytes?: number /** Maximum simultaneous normalization or request-image transformations in this service instance. */ imageCompressionConcurrency?: number diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts index e9ecd8d3e7..4d679912a8 100644 --- a/packages/attachment/attachment-local/src/normalization.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -11,7 +11,7 @@ import type { DetectedImage } from './image.ts' export interface NormalizationPolicy { /** Long-edge cap in pixels; larger sources are downscaled proportionally. */ maxDimension: number - /** Independent safety cap for encoded normalized image bytes. */ + /** Encoded-byte target for the quality ladder; the smallest ladder output is kept when no quality fits. */ maxBytes: number } @@ -23,23 +23,20 @@ export interface NormalizedImage { height: number } -const NORMALIZATION_QUALITIES = [85, 80, 75] as const -const LOW_COLOUR_SAMPLE_EDGE = 128 -const LOW_COLOUR_LIMIT = 256 -const MIN_SCALE_STEP = 0.9 +/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */ +export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const +/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */ +export const WEBP_ENCODING_EFFORT = 0 /** Encode one prepared pipeline and report exact output facts. */ async function encode( pipeline: Sharp, - mediaType: 'image/png' | 'image/jpeg' | 'image/webp', - quality?: number, - palette = true, + mediaType: 'image/jpeg' | 'image/webp', + quality: number, ): Promise { - const encoded = mediaType === 'image/png' - ? pipeline.png({ compressionLevel: 9, palette }) - : mediaType === 'image/webp' - ? pipeline.webp({ quality }) - : pipeline.jpeg({ quality }) + const encoded = mediaType === 'image/webp' + ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT }) + : pipeline.jpeg({ quality }) const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } } @@ -65,32 +62,6 @@ export function canPassThroughNormalization( && Math.max(detected.width, detected.height) <= policy.maxDimension } -/** - * Classify a bounded pixel sample without assuming that a PNG source is a screenshot. - * @param pipeline - oriented sRGB source pipeline before output resizing. - * @returns whether the nearest-neighbour sample stays within the low-color threshold. - */ -export async function hasLowColourCount(pipeline: Sharp): Promise { - const { data, info } = await pipeline.clone().resize({ - width: LOW_COLOUR_SAMPLE_EDGE, - height: LOW_COLOUR_SAMPLE_EDGE, - fit: 'inside', - withoutEnlargement: true, - kernel: sharp.kernel.nearest, - fastShrinkOnLoad: false, - }).raw().toBuffer({ resolveWithObject: true }) - const colours = new Set() - for (let offset = 0; offset < data.length; offset += info.channels) { - const red = data.readUInt8(offset) - const green = data.readUInt8(offset + 1) - const blue = data.readUInt8(offset + 2) - const alpha = info.channels === 4 ? data.readUInt8(offset + 3) : 255 - colours.add(((red >> 3) << 15) | ((green >> 3) << 10) | ((blue >> 3) << 5) | (alpha >> 3)) - if (colours.size > LOW_COLOUR_LIMIT) return false - } - return true -} - /** Assert that a normalized output is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ async function verifyNormalizedImage( image: NormalizedImage, @@ -130,32 +101,26 @@ function initialDimensions(detected: DetectedImage, maxDimension: number): { wid } } -/** Lazy encoding order for one size, separated by sampled colour complexity and alpha. */ -function encodingAttemptsAtSize( +/** Lazy quality ladder: WebP keeps a source alpha channel, everything else is JPEG. */ +function encodingAttempts( data: Uint8Array, width: number, height: number, hasAlpha: boolean, - lowColour: boolean, ): Array<() => Promise> { const prepared = preparedPipeline(data, width, height) - const webp = NORMALIZATION_QUALITIES.map(quality => ( - () => encode(prepared.clone(), 'image/webp', quality) - )) - if (lowColour) { - return [() => encode(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] - } - if (hasAlpha) return webp - return NORMALIZATION_QUALITIES.map(quality => ( - () => encode(prepared.clone(), 'image/jpeg', quality) + const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' + return IMAGE_ENCODING_QUALITIES.map(quality => ( + () => encode(prepared.clone(), mediaType, quality) )) } /** * Produce the persisted provider-independent normalized version of one fully decoded source. * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, - * and inside both normalization limits. Re-encoding never removes transparency. After the fixed - * quality floor is reached, dimensions continue shrinking until the independent byte cap holds. + * and inside both normalization limits. Re-encoding never removes transparency. When every + * ladder quality exceeds the byte target, the smallest ladder output is kept; provider byte + * caps stay enforced at the route that transmits the bytes. * @param data - complete admitted source bytes. * @param detected - fully decoded source facts. * @param policy - resolved independent normalization limits. @@ -170,27 +135,13 @@ export async function normalizeImage( return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } } try { - let { width, height } = initialDimensions(detected, policy.maxDimension) - const classificationPipeline = sharp(data, { failOn: 'error', limitInputPixels: false }) - .rotate() - .toColourspace('srgb') - const lowColour = await hasLowColourCount(classificationPipeline) - for (;;) { - const encoded = await encodeFirstWithinLimit( - encodingAttemptsAtSize(data, width, height, detected.hasAlpha, lowColour), - policy.maxBytes, - ) - if (!isExhaustedEncoding(encoded)) { - return await verifyNormalizedImage(encoded, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) - } - if (width === 1 && height === 1) break - const sizeScale = Math.sqrt(policy.maxBytes / encoded.smallest.data.byteLength) * 0.95 - const scale = Math.min(MIN_SCALE_STEP, sizeScale) - const nextWidth = Math.max(1, Math.floor(width * scale)) - const nextHeight = Math.max(1, Math.floor(height * scale)) - width = nextWidth - height = nextHeight - } + const { width, height } = initialDimensions(detected, policy.maxDimension) + const encoded = await encodeFirstWithinLimit( + encodingAttempts(data, width, height, detected.hasAlpha), + policy.maxBytes, + ) + const chosen = isExhaustedEncoding(encoded) ? encoded.smallest : encoded + return await verifyNormalizedImage(chosen, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) } catch (error) { if (error instanceof AttachmentError) throw error const source = detected.mediaType === 'image/png' && detected.depth !== 'uchar' @@ -202,5 +153,4 @@ export async function normalizeImage( { cause: error }, ) } - throw new AttachmentError('Image cannot be encoded within the configured normalized-image byte cap.', 'IMAGE_TOO_LARGE') } diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 66c427480b..1764eded3a 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -12,14 +12,12 @@ import type { RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { hasLowColourCount } from './normalization.ts' +import { IMAGE_ENCODING_QUALITIES, WEBP_ENCODING_EFFORT } from './normalization.ts' import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ -export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v4' -/** DeepSeek request versions normally fit at these two preferred qualities. */ -export const REQUEST_IMAGE_QUALITIES = [85, 80] as const +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5' interface EncodedRequestImage { data: Uint8Array @@ -87,10 +85,10 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): routePixelBudget: policy.maxPixels, encodedByteBudget: policy.maxBytes, encoding: { - png: { compressionLevel: 9, palette: 'opaque-only' }, - webpQualities: REQUEST_IMAGE_QUALITIES, - jpegQualities: REQUEST_IMAGE_QUALITIES, - order: ['low-colour:png-webp', 'alpha:webp', 'opaque:jpeg'], + webpQualities: IMAGE_ENCODING_QUALITIES, + webpEffort: WEBP_ENCODING_EFFORT, + jpegQualities: IMAGE_ENCODING_QUALITIES, + order: ['alpha:webp', 'opaque:jpeg'], colourspace: 'srgb', }, }) @@ -120,15 +118,12 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp { async function encoded( image: Sharp, - mediaType: 'image/png' | 'image/jpeg' | 'image/webp', - quality?: number, - palette = true, + mediaType: 'image/jpeg' | 'image/webp', + quality: number, ): Promise { - const output = mediaType === 'image/png' - ? image.png({ compressionLevel: 9, palette }) - : mediaType === 'image/webp' - ? image.webp({ quality }) - : image.jpeg({ quality }) + const output = mediaType === 'image/webp' + ? image.webp({ quality, effort: WEBP_ENCODING_EFFORT }) + : image.jpeg({ quality }) const { data, info } = await output.toBuffer({ resolveWithObject: true }) return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } } @@ -138,16 +133,11 @@ function encodingAttempts( width: number, height: number, hasAlpha: boolean, - lowColour: boolean, ): Array<() => Promise> { const prepared = pipeline(attachment, width, height) - const webp = REQUEST_IMAGE_QUALITIES.map(quality => ( - () => encoded(prepared.clone(), 'image/webp', quality) - )) - if (lowColour) return [() => encoded(prepared.clone(), 'image/png', undefined, !hasAlpha), ...webp] - if (hasAlpha) return webp - return REQUEST_IMAGE_QUALITIES.map(quality => ( - () => encoded(prepared.clone(), 'image/jpeg', quality) + const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' + return IMAGE_ENCODING_QUALITIES.map(quality => ( + () => encoded(prepared.clone(), mediaType, quality) )) } @@ -156,7 +146,7 @@ async function createRequestImage( policy: ImageRequestPolicy, hasAlpha: boolean, ): Promise { - let dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) if (dimensions.width === attachment.ref.width && dimensions.height === attachment.ref.height && attachment.data.byteLength <= policy.maxBytes) { @@ -167,21 +157,11 @@ async function createRequestImage( height: attachment.ref.height, } } - const lowColour = await hasLowColourCount(sourcePipeline(attachment)) - for (;;) { - const encodedVersion = await encodeFirstWithinLimit( - encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha, lowColour), - policy.maxBytes, - ) - if (!isExhaustedEncoding(encodedVersion)) return encodedVersion - if (dimensions.width === 1 && dimensions.height === 1) break - const scale = Math.min(0.9, Math.sqrt(policy.maxBytes / encodedVersion.smallest.data.byteLength) * 0.95) - dimensions = { - width: Math.max(1, Math.floor(dimensions.width * scale)), - height: Math.max(1, Math.floor(dimensions.height * scale)), - } - } - throw new AttachmentError('Image cannot be encoded within the model-request byte budget.', 'IMAGE_TOO_LARGE') + const encodedVersion = await encodeFirstWithinLimit( + encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha), + policy.maxBytes, + ) + return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion } function cachePath(root: string, hash: string): string { @@ -199,7 +179,7 @@ async function readCached( const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) - if (data.byteLength > policy.maxBytes || detected.depth !== 'uchar' || detected.space !== 'srgb' + if (detected.depth !== 'uchar' || detected.space !== 'srgb' || detected.width > maximum.width || detected.height > maximum.height || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha } diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index f8deea3c5c..4c10fa5033 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -108,15 +108,15 @@ describe('local attachment service', () => { it('prepares every batch member before any write', async () => { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-')) try { - const service = new LocalAttachmentStore(new Context(), { dshHome, normalizedImageMaxBytes: 1 }) + const service = new LocalAttachmentStore(new Context(), { dshHome }) const valid = Uint8Array.from(Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) await expect(service.saveImages([ { data: valid, mediaType: 'image/png' }, - { data: valid, mediaType: 'image/png' }, - ])).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }, + ])).rejects.toThrow(/Unsupported or malformed image data/) expect(existsSync(service.root)).toBe(false) } finally { await rm(dshHome, { recursive: true, force: true }) diff --git a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts new file mode 100644 index 0000000000..4e39223f24 --- /dev/null +++ b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts @@ -0,0 +1,38 @@ +import sharp from 'sharp' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const control = vi.hoisted(() => ({ mismatch: false })) + +vi.mock('../src/image.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async detectImage(data: Uint8Array): Promise>> { + const detected = await actual.detectImage(data) + return control.mismatch ? { ...detected, width: detected.width + 1 } : detected + }, + } +}) + +import { normalizeImage } from '../src/normalization.ts' +import { detectImage } from '../src/image.ts' + +afterEach(() => { + control.mismatch = false +}) + +describe('normalization verification', () => { + it('rejects a normalized output whose decoded facts disagree with the encoder result', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 10, height: 6, channels: 3, background: { r: 12, g: 200, b: 64 } }, + }).png().toBuffer()) + const detected = await detectImage(data) + control.mismatch = true + + await expect(normalizeImage(data, detected, { maxDimension: 5, maxBytes: 4 * 1024 * 1024 })) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', + }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/normalization.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts index d43ae45bcd..6403e1a0b4 100644 --- a/packages/attachment/attachment-local/tests/normalization.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import sharp from 'sharp' -import { hasLowColourCount, canPassThroughNormalization, normalizeImage } from '../src/normalization.ts' +import { canPassThroughNormalization, normalizeImage } from '../src/normalization.ts' import type { NormalizationPolicy } from '../src/normalization.ts' import { detectImage } from '../src/image.ts' @@ -72,14 +72,14 @@ describe('normalizeImage', () => { }) }) - it('downscales an oversized PNG to the long-edge target and stays PNG', async () => { + it('downscales an oversized opaque PNG to the long-edge target as JPEG', async () => { const data = await flatImage(10, 6, 'png') const detected = await detectImage(data) const normalized = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) - expect(normalized).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 }) - await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) const again = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(again.data).toEqual(normalized.data) }) @@ -93,23 +93,26 @@ describe('normalizeImage', () => { expect(second.data).toBe(first.data) }) - it('always re-encodes GIF to the PNG of its first frame', async () => { + it('always re-encodes GIF as a single still frame', async () => { const data = await flatImage(6, 4, 'gif') const detected = await detectImage(data) const normalized = await normalizeImage(data, detected, POLICY) - expect(normalized.mediaType).toBe('image/png') - await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + // gifload always decodes to RGBA, so a GIF re-encodes on the WebP ladder. + expect(detected.hasAlpha).toBe(true) + expect(normalized.mediaType).toBe('image/webp') + await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) }) - it('keeps a low-colour alpha source on PNG when the budget holds', async () => { + it('keeps a transparent source on the WebP ladder', async () => { const data = await flatImage(9, 5, 'webp', true) const detected = await detectImage(data) const normalized = await normalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) - expect(normalized).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 }) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 4, height: 2 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) }) it('accepts WebP output that omits an all-opaque source alpha plane', async () => { @@ -137,7 +140,7 @@ describe('normalizeImage', () => { await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: false }) }) - it('keeps transparency when the byte cap requires another encoding and smaller dimensions', async () => { + it('keeps the smallest transparent ladder output above an unreachable byte target without shrinking', async () => { const side = 128 const pixels = new Uint8Array(side * side * 4) const noise = noisePixels(side, side) @@ -153,8 +156,8 @@ describe('normalizeImage', () => { const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) - expect(normalized.data.byteLength).toBeLessThanOrEqual(1_024) - expect(normalized.width).toBeLessThan(side) + expect(normalized.data.byteLength).toBeGreaterThan(1_024) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: side, height: side }) await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) }) @@ -167,10 +170,7 @@ describe('normalizeImage', () => { expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) }) - it('classifies a photographic PNG by pixels and uses an opaque photographic encoding', async () => { - // A smooth gradient: palette quantization dithers it into a sizable PNG - // while JPEG at quality 85 stays far smaller, so the budget between the - // two forces exactly one ladder hop. + it('re-encodes an opaque gradient PNG as JPEG within the byte target', async () => { const side = 256 const pixels = new Uint8Array(side * side * 3) for (let y = 0; y < side; y += 1) { @@ -192,14 +192,13 @@ describe('normalizeImage', () => { expect(normalized.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) }) - it('shrinks dimensions after the quality floor instead of refusing an oversized encoding', async () => { + it('keeps the smallest opaque ladder output above an unreachable byte target', async () => { const data = await noiseImage(64, 64, 'png') const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) - expect(normalized.data.byteLength).toBeLessThanOrEqual(512) - expect(normalized.width).toBeLessThan(64) - expect(normalized.height).toBeLessThan(64) + expect(normalized.data.byteLength).toBeGreaterThan(512) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 64, height: 64 }) }) it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => { @@ -263,86 +262,7 @@ describe('normalizeImage', () => { }) }) - it('rejects a converted normalized image whose verified alpha metadata disagrees with the source facts', async () => { - const data = await flatImage(8, 8, 'png', true) - const detected = await detectImage(data) - - await expect(normalizeImage(data, { ...detected, hasAlpha: false }, { - maxDimension: 4, - maxBytes: POLICY.maxBytes, - })).rejects.toMatchObject({ - code: 'ATTACHMENT_WRITE_FAILED', - message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', - }) - }) -}) - -describe('hasLowColourCount', () => { - it('distinguishes photographic rasters from low-colour graphics without averaged sampling', async () => { - const side = 512 - const highFrequency = sharp(noisePixels(side, side), { raw: { width: side, height: side, channels: 3 } }) - const gradientPixels = new Uint8Array(side * side * 3) - for (let y = 0; y < side; y += 1) { - for (let x = 0; x < side; x += 1) { - const offset = (y * side + x) * 3 - gradientPixels[offset] = x & 0xff - gradientPixels[offset + 1] = y & 0xff - gradientPixels[offset + 2] = (x * 3 + y * 5) & 0xff - } - } - const ordinaryPhoto = sharp(gradientPixels, { raw: { width: side, height: side, channels: 3 } }) - const solid = sharp({ - create: { width: side, height: side, channels: 3, background: { r: 12, g: 34, b: 56 } }, - }) - const text = sharp(Buffer.from(` - - - DeepSeek 16-bit - - `)) - const transparentData = await sharp({ - create: { width: side, height: side, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }).composite([{ input: Buffer.from(` - - - - `) }]).png().toBuffer() - const transparent = sharp(transparentData) - - await expect(hasLowColourCount(highFrequency)).resolves.toBe(false) - await expect(hasLowColourCount(ordinaryPhoto)).resolves.toBe(false) - await expect(hasLowColourCount(solid)).resolves.toBe(true) - await expect(hasLowColourCount(text)).resolves.toBe(true) - await expect(hasLowColourCount(transparent)).resolves.toBe(true) - }) - - it('reads grayscale-alpha samples without treating alpha or the next pixel as RGB', async () => { - const symbols: number[] = [] - for (let first = 0; first < 32; first += 1) { - for (let second = 0; second < 32; second += 1) symbols.push(first, second) - } - const pixels = new Uint8Array(symbols.length * 2) - for (const [index, symbol] of symbols.entries()) { - pixels[index * 2] = symbol * 8 - pixels[index * 2 + 1] = symbol * 8 - } - const grayscaleAlpha = sharp(pixels, { - raw: { width: 128, height: 16, channels: 2 }, - }) - - await expect(hasLowColourCount(grayscaleAlpha)).resolves.toBe(true) - }) - - it('reads one-channel grayscale samples as equal RGB values', async () => { - const pixels = new Uint8Array(128 * 16) - for (let index = 0; index < pixels.length; index += 1) pixels[index] = index & 0xff - - await expect(hasLowColourCount(sharp(pixels, { - raw: { width: 128, height: 16, channels: 1 }, - }))).resolves.toBe(true) - }) - - it('keeps an antialiased text screenshot readable on the low-colour PNG path', async () => { + it('keeps an antialiased text screenshot readable on the JPEG ladder', async () => { const source = new Uint8Array(await sharp(Buffer.from(` @@ -356,7 +276,7 @@ describe('hasLowColourCount', () => { }) const stats = await sharp(normalized.data).greyscale().stats() - expect(normalized).toMatchObject({ mediaType: 'image/png', width: 512, height: 256 }) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 512, height: 256 }) expect(stats.channels[0]?.min).toBeLessThan(80) expect(stats.channels[0]?.max).toBeGreaterThan(240) }) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 66932b5e52..e470b4c657 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -97,12 +97,15 @@ describe('local request-image cache', () => { .rejects.toThrow('Image request maxBytes must be a positive integer') }) - it('refuses a one-pixel request that cannot meet the encoded-byte budget', async () => { + it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })) - .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 }) + + expect(request.mediaType).toBe('image/jpeg') + expect(request.bytes).toBeGreaterThan(1) + expect(request).toMatchObject({ width: 1, height: 1 }) }) it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { @@ -171,7 +174,7 @@ describe('local request-image cache', () => { expect(low.width * low.height).toBeLessThanOrEqual(512 * 512 + low.width) }) - it('classifies opaque PNG pixels and preserves alpha while enforcing the request budget', async () => { + it('routes opaque pixels to JPEG and preserves alpha on the WebP ladder', async () => { const attachments = await store() const side = 256 const photoPixels = new Uint8Array(side * side * 3) @@ -204,8 +207,9 @@ describe('local request-image cache', () => { const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) expect(photoRequest.mediaType).toBe('image/jpeg') - expect(alphaRequest.bytes).toBeLessThanOrEqual(4_096) - expect(alphaRequest.width).toBeLessThan(128) + expect(alphaRequest.mediaType).toBe('image/webp') + expect(alphaRequest.bytes).toBeGreaterThan(4_096) + expect(alphaRequest).toMatchObject({ width: 128, height: 128 }) await expect(sharp(alphaRequest.data).metadata()).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index ad29f856ec..e1a729d90e 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -149,7 +149,7 @@ describe('local attachment store', () => { }, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 }) expect(saved).toMatchObject({ - mediaType: 'image/png', + mediaType: 'image/jpeg', width: 2, height: 2, name: 'big.png', diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index e23a7a7d4c..046444cd76 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -71,7 +71,7 @@ export interface StoredImageAttachment { export interface ImageRequestPolicy { /** Maximum width multiplied by height after aspect-preserving projection. */ maxPixels: number - /** Encoded-byte cap before base64 expansion or Files API upload. */ + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 8076a692e6..2bf1a55649 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 7d50a8e99863637a06abf49d26a6eeb419cf1bbd -README.zh.md: c1571149529a2d4e54d67b10f63b60bb2aa1abfd +README.md: 5f03254479b81d294944197c55ffec0c59cdc94b +README.zh.md: 463d2de6774c868e7c2daa2e4772c28c8cce9cc0 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7d50a8e998..5f03254479 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -52,7 +52,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. -An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: low-color images try PNG (palette only without alpha) then WebP 85 and 80, other alpha images try WebP 85 then 80, and other opaque images try JPEG 85 then 80; dimensions shrink only when both quality attempts exceed 1MiB. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. +An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget`, `imageMaxBytes`, or `imageDetail: low`. The ordinary default is 640,000 total pixels and 1MiB encoded bytes; low detail defaults to 512 by 512 total pixels. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: alpha images try WebP (effort 0) at 85, 75, then 60, and opaque images try JPEG at those qualities; when every quality exceeds 1MiB the smallest output is used. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by stable text naming the complete attachment id and actual request dimensions. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. `maxRequestFilesBytes` and `maxImagesPerRequest` bound the retained request versions at 128MiB and 600 images by default. The byte and count quanta must not exceed their corresponding bounds. Before attachment reads, the adapter uses each route's request-version byte cap as a conservative upper bound and removes the oldest over-budget prefix; only retained normalized attachments are read and transformed. Exact derived lengths are checked again without restoring omitted images. When the byte bound is crossed, the oldest prefix advances past the next 64MiB boundary; 129 one-megabyte images remove the oldest 65 and retain 64MiB, and that prefix stays unchanged until durable history exceeds 192MiB. Count overflow advances independently in `imageOffloadCountQuantum` steps. Removed images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`. This high-watermark projection avoids changing an old request prefix after every new image. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c157114952..463d2de677 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -52,7 +52,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 -支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:低色数图片先尝试 PNG,只有不带 alpha 通道时才使用 palette,再尝试质量 85 和 80 的 WebP;其他透明图片依次尝试质量 85 和 80 的 WebP;其他非透明图片依次尝试质量 85 和 80 的 JPEG。两个质量档均超过 1MiB 时才缩小尺寸。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 +支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可设置 `imagePixelBudget`、`imageMaxBytes` 或 `imageDetail: low`。普通默认值为总像素 640,000、编码字节 1MiB;low detail 的默认总像素为 512×512。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:透明图片依次尝试质量 85、75、60 的 WebP(effort 0);非透明图片依次尝试这些质量的 JPEG。全部质量档都超过 1MiB 时使用其中最小的产物。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有稳定文本,写明完整附件 ID 和实际请求尺寸。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 `maxRequestFilesBytes` 和 `maxImagesPerRequest` 限制请求中保留的请求版本,默认值分别为 128MiB 和 600 张。字节和数量步长不得超过对应上限。读取附件前,适配器以路由的请求版本字节上限作为保守上界,移除超预算的最旧前缀,只读取并转换保留的规范化附件。系统随后用确切派生长度再次检查,但不会重新加入已省略图片。字节数越过上限时,被移除的最旧前缀会越过下一个 64MiB 边界。由 1MiB 图片组成的历史达到 129MiB 时会移除最旧的 65 张并保留 64MiB;直到持久历史超过 192MiB,这个前缀才再次变化。图片数量超限时则按 `imageOffloadCountQuantum` 独立递增。移除的图片会变成固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`。这种定量投影不会因每新增一张图片就改写较早的请求前缀。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index fe461fdb35..086243ad41 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -60,7 +60,7 @@ export interface DeepSeekCatalogModel { inputModalities?: ModelModality[] /** Total-pixel budget for one deterministic request preview. */ imagePixelBudget?: number - /** Encoded-byte cap for one deterministic request preview. */ + /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number /** Provider detail tier; `low` uses the 512-by-512 total-pixel default. */ imageDetail?: 'auto' | 'low' diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 28e409e734..b01b3521ff 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -169,7 +169,10 @@ export interface PiAiProviderProfile { maxRequestImageBytes?: number /** Total-pixel budget for each deterministic inline request version. */ requestImagePixelBudget?: number - /** Raw encoded-byte cap for each deterministic inline request version. */ + /** + * Raw encoded-byte target for each deterministic inline request version; + * the smallest quality-ladder output is used when no quality fits. + */ requestImageMaxBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig From 30704dc1df10a54c31d0ad6513bcc2690feaf9de Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 17:10:59 +0800 Subject: [PATCH 18/42] fix(attachment): budget master pixels and share the encoding ladder Master dimensions move from a 2048 long-edge rule to a total-pixel budget (normalizedImageMaxPixels, default 2048x2048) with an 8192 long-edge cap, so extreme aspect ratios keep short-edge resolution. The shared quality ladder and lazy execution move to encoding.ts, review-round doc fixes land across attachment and llm packages, and the superseded facts in the unified-image-pipeline note now describe the shipped routing. --- ...pha-routed-image-quality-ladders.i18n.yaml | 4 +- ...8-24-alpha-routed-image-quality-ladders.md | 8 ++- ...4-alpha-routed-image-quality-ladders.zh.md | 8 ++- ...0-unified-image-request-pipeline.i18n.yaml | 4 +- ...26-08-20-unified-image-request-pipeline.md | 10 ++-- ...08-20-unified-image-request-pipeline.zh.md | 10 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 6 +- docs/config-catalog.zh.md | 6 +- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 4 +- .../attachment/attachment-local/README.zh.md | 4 +- .../attachment-local/src/encoding.ts | 43 +++++++++++++- .../attachment/attachment-local/src/index.ts | 18 ++++-- .../attachment-local/src/normalization.ts | 59 ++++++------------- .../attachment-local/src/request-image.ts | 36 +++-------- .../attachment-local/tests/index.spec.ts | 2 + .../tests/normalization-verification.spec.ts | 2 +- .../tests/normalization.spec.ts | 52 ++++++++++++---- .../attachment-local/tests/store.spec.ts | 4 +- packages/attachment/attachment/src/index.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/config.ts | 4 +- 30 files changed, 176 insertions(+), 138 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml index 344499741c..e2e60901af 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.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/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md -2026-08-24-alpha-routed-image-quality-ladders.md: 16b3213268999d846f0b2a5ab63b3f5ae4adc332 -2026-08-24-alpha-routed-image-quality-ladders.zh.md: dde9ba60996cdfb779a6a1a88810fec608bc0609 +2026-08-24-alpha-routed-image-quality-ladders.md: cdf5e7bd908782781e66a4f6494428f0f174e258 +2026-08-24-alpha-routed-image-quality-ladders.zh.md: fbe8215b0ca1c606cd66d60fe1109d2f9aa1cbad diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md index 16b3213268..cdf5e7bd90 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md @@ -10,15 +10,17 @@ Image normalization and request-image encoding in `@deepseek-ai/dsh-attachment-l ## Decision -Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` in `normalization.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id. +Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` / `encodingLadder` in `encoding.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. Master dimensions move from a long-edge rule to a total-pixel budget: `normalizedImageMaxPixels` (default 2048x2048) scales the raster proportionally and `normalizedImageMaxDimension` (default 8192, matching the admission per-side cap) clamps the long edge afterwards, so extreme aspect ratios such as tall page screenshots keep their short-edge resolution (a 2000x20000 source keeps about 647px of width instead of 204px) while square sources normalize exactly as before. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id. Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices) back the choice: on photographic content JPEG is one to two orders of magnitude faster than every alternative, and WebP at effort 0 matches palette PNG's size on graphics content while never being misrouted; uniform-noise worst cases fit the default 4MiB/1MiB targets at quality 85 for opaque sources, and only an adversarial random-alpha plane exhausts the WebP ladder (about 6.3MiB, five times under the provider cap). +This decision partially supersedes the [unified image request pipeline note](../feature/2026-08-20-unified-image-request-pipeline.md), whose normalization and request-encoding sections now describe this routing; its durable-version split, Files lifecycle, and offload projection stand unchanged. + ## Alternatives considered **Repair the classifier (higher-resolution sampling, gradient statistics) and keep palette PNG.** Rejected: any content classifier retains a misrouting class and the per-image classification decode; palette PNG's only frontier niche (graphics) is matched by WebP at a fraction of the encode time. -**A single WebP ladder for everything.** Rejected in review discussion: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing. +**A single WebP ladder for everything.** Rejected: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing. **Keep the downscale retry loop for ladder-exhausted outputs.** Rejected: measured worst cases show the loop is dead code within default budgets, and its only reachable effect was degrading adversarial inputs to 1×1 before erroring. @@ -27,5 +29,5 @@ Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices) - Opaque low-colour graphics (charts, text screenshots) now store as JPEG: two to three times larger than palette PNG in the hundreds-of-kilobytes range, with JPEG ringing on hard edges; the model-visible request version was already dominated by pixel-budget downscaling, so legibility impact is marginal. Reintroducing a graphics codec would add a WebP step to the opaque ladder, not restore classification. - GIF sources decode with an alpha plane under gifload, so still-frame GIFs normalize onto the WebP ladder. - `IMAGE_TOO_LARGE` no longer arises from encoding; it remains the admission error for oversized sources. -- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input. +- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input. Re-submitting such an over-target master as a new upload fails the pass-through byte check and re-encodes it down the lossy ladder again, so normalization is not idempotent for this adversarial-only class and each round adds generation loss. - Test evidence: `packages/attachment/attachment-local/tests` pins the routing, ladder-exhaustion, and readable-text behavior against real encoders, including the issue #2885 misrouting characteristics (high-frequency photographic content leaving the slow path). diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md index dde9ba6099..fbe8215b0c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md @@ -10,15 +10,17 @@ Status: implemented ## 决定 -两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`normalization.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。 +两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`encoding.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` / `encodingLadder`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。master 尺寸规则从长边上限改为总像素预算:`normalizedImageMaxPixels`(默认 2048×2048)按比例缩放,`normalizedImageMaxDimension`(默认 8192,与准入单边上限一致)随后夹住长边,因此长页面截图这类极端长宽比保留短边分辨率(2000×20000 的源图短边保留约 647px 而不是 204px),正方形源图的规范化结果与之前完全一致。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。 对 issue #2885 复现集的 Pareto 实测(PR #2989 附录)支撑这个选择:摄影类内容上 JPEG 比其余所有编码器快 1 至 2 个数量级,effort 0 的 WebP 在图形类内容上体积与 palette PNG 相当且不会被误判;均匀噪声最坏输入在不透明链的 q85 一档即落入默认 4MiB/1MiB 目标,只有对抗性的随机 alpha 平面会耗尽 WebP 阶梯(约 6.3MiB,距提供方上限还有 5 倍)。 +本决定部分取代[统一图片请求管线记录](../feature/2026-08-20-unified-image-request-pipeline.zh.md):其规范化与请求编码章节现在以本路由为准;其耐久版本拆分、Files 生命周期与卸载投影不变。 + ## 考虑过的替代方案 **修复分类器(提高采样分辨率、加入梯度统计)并保留 palette PNG。** 否决:任何内容分类器都保留一类误判和每张图的分类解码成本;palette PNG 唯一的前沿生态位(图形类)WebP 用远少的编码时间即可达到。 -**全部走单一 WebP 阶梯。** 在评审讨论中否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。 +**全部走单一 WebP 阶梯。** 否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。 **为阶梯耗尽的产物保留缩图重试循环。** 否决:实测最坏情况表明该循环在默认预算内是死代码,其唯一可达效果是把对抗性输入一路缩到 1×1 再报错。 @@ -27,5 +29,5 @@ Status: implemented - 不透明的低色数图形(图表、文字截图)现在存为 JPEG:在几百 KB 量级上比 palette PNG 大 2 至 3 倍,锐利边缘有 JPEG 振铃;模型可见的请求版本本就被像素预算缩尺寸主导,可读性影响很小。将来若需要图形类专用编码,正确做法是给不透明阶梯加一档 WebP,而不是恢复分类。 - GIF 源图经 gifload 解码后带 alpha 平面,因此静帧 GIF 规范化走 WebP 阶梯。 - `IMAGE_TOO_LARGE` 不再产生于编码环节;它仍是超大源图的准入错误。 -- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。 +- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。把这样的超目标 master 再次作为新上传提交时,直通的字节检查不通过,会再走一遍有损阶梯,因此规范化对这一仅对抗性可达的类别不幂等,每轮都会累积代际损失。 - 测试证据:`packages/attachment/attachment-local/tests` 用真实编码器钉住路由、阶梯耗尽和文字可读性行为,包括 issue #2885 误判特征(高频摄影内容离开慢路径)。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index 66ba6d1139..55331d4816 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 85296f1d8bd7d6ee458f8bc4230e52f1d3128bff -2026-08-20-unified-image-request-pipeline.zh.md: bcbc0110001e9f974e57a456140e9ecdf4eb888c +2026-08-20-unified-image-request-pipeline.md: 1c7a8040232bab0d73f578a9170e235c2f175c07 +2026-08-20-unified-image-request-pipeline.zh.md: af6497f5a06018b87d166842615f6b1e12b1aed4 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 85296f1d8b..1c7a804023 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -14,17 +14,17 @@ The image path has two explicit versions. The attachment backend owns a provider ### Provider-independent normalized attachment -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `normalizedImageMaxDimension`, 2048px by default. When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while scaling into the `normalizedImageMaxPixels` total-pixel budget (2048x2048 by default) under a `normalizedImageMaxDimension` long-edge cap (8192px by default). When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization. -The normalized attachment has an independent `normalizedImageMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both normalization limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. +The normalized attachment has an independent `normalizedImageMaxBytes` encoded-byte target, 4MiB by default. Alpha is never flattened. Codec routing is by the decoded alpha fact alone — alpha input encodes as WebP (effort 0) and opaque input as JPEG, each down the shared 85/75/60 quality ladder; when every quality exceeds the target the smallest output is kept, per the superseding [alpha-routed quality ladders note](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md). A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within every normalization limit passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. Batch admission prepares and verifies every normalized attachment once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. ### Deterministic request versions -`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and a 1MiB raw encoded-byte target by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same alpha routing and 85/75/60 quality ladder as normalization, executed lazily; a target no quality meets keeps the smallest ladder output (see the [alpha-routed quality ladders note](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md)). The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. -The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. +The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, and alpha without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references. @@ -62,7 +62,7 @@ Historical attachment objects that later disappear or fail integrity verificatio ## Verification -Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index bcbc011000..af6497f5a0 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -14,17 +14,17 @@ Status: implemented ### 提供方无关的规范化附件 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`,默认 2048px。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比缩放进 `normalizedImageMaxPixels` 总像素预算(默认 2048×2048),随后受 `normalizedImageMaxDimension` 长边上限约束(默认 8192px)。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。 -规范化附件有独立的 `normalizedImageMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 +规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标,默认 4MiB。透明通道绝不铺平。编码路由只看解码出的 alpha 事实:透明输入编码为 WebP(effort 0),非透明输入编码为 JPEG,共用 85/75/60 质量阶梯;全部档位都超过目标时保留最小产物,见取代本节的[按 alpha 路由的质量阶梯记录](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md)。处于全部规范化限制内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 批量准入在发布任何成员前,为每张图片各准备并验证一次规范化附件。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 ### 确定性请求版本 -`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节目标 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码与规范化共用同一套 alpha 路由和 85/75/60 质量阶梯,按需执行;没有任何档位达到目标时保留最小产物(见[按 alpha 路由的质量阶梯记录](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md))。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 -`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 +`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸和透明通道,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。 @@ -62,7 +62,7 @@ Status: implemented ## Verification -包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 ## Consequences diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9c6eb14a09..de34a153cf 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: bbcfbef6dc98f8739c825bca0b2807ca58a1d144 -config-catalog.zh.md: f1db1c5b7d78ee32fd9a0e0d22bcdf8ebb17abb1 +config-catalog.md: 46228907ea4241e69a4439c2dc1c1786de94eaff +config-catalog.zh.md: ed09a3d44f93815ab667ef4613249a33306d9899 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bbcfbef6dc..46228907ea 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -308,7 +308,9 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent normalized image. */ + /** Total-pixel budget of the stored provider-independent normalized image. */ + normalizedImageMaxPixels?: number + /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */ normalizedImageMaxDimension?: number /** * Encoded-byte target of the stored provider-independent normalized image; @@ -320,7 +322,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f1db1c5b7d..ed09a3d44f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -310,7 +310,9 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent normalized image. */ + /** Total-pixel budget of the stored provider-independent normalized image. */ + normalizedImageMaxPixels?: number + /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */ normalizedImageMaxDimension?: number /** * Encoded-byte target of the stored provider-independent normalized image; @@ -322,7 +324,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 195f6acaf3..8e69eaae28 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: aa29c5fc6c011f31fbdb3d4fb9f218d0801e5205 -attachment.zh.md: 9ee90794d28f7cfa0d7b30986aa33d6cbc298f8f +attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11 +attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index aa29c5fc6c..15daa2b8d5 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -187,7 +187,7 @@ imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel and encoded-byte budget. + * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 9ee90794d2..c74a18f6be 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -187,7 +187,7 @@ imageHostPath(ref: ImageAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel and encoded-byte budget. + * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 2828e95d6a..353d5d1d7b 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: dd779f5e9af7d97d871134296ac95d5536649b22 -README.zh.md: 0c92838c91b2e72eb80b34c0b9780f685642ceed +README.md: 7bd0283e7f922ecfa2be4b67e296f5c0016f4302 +README.zh.md: 6e8ad367cf7e81f373c112e27cace07491638e0f diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index dd779f5e9a..7bd0283e7f 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, a synced temporary file, an atomic exclusive hard-link publish, owner-read-only object permissions, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. -Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the raster is reduced proportionally to the `normalizedImageMaxPixels` total-pixel budget (2048x2048 by default) with a `normalizedImageMaxDimension` long-edge cap (8192px by default), so extreme aspect ratios keep their short-edge resolution instead of collapsing under a long-edge rule. The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimension, and alpha facts before use; a mismatch regenerates the entry. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation. `DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. `imageHostPath` derives the normalized object's absolute host path and does not inspect the tool execution world. At request assembly, an LLM consumer asks the mounted filesystem to map that host object into its execution world. A host-backed filesystem returns a process path; a remote filesystem without a shared mount returns no path. The mapped path is absent from durable history and from `RequestImageAttachment`. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 0c92838c91..6e8ad367cf 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,9 +4,9 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、经过同步的临时文件、原子且排他的硬链接发布、仅所有者可读的对象权限,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。 -每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把像素总量缩到 `normalizedImageMaxPixels` 总像素预算内(默认 2048×2048),再受 `normalizedImageMaxDimension` 长边上限约束(默认 8192px),因此极端长宽比的图保留短边分辨率,而不会在长边规则下坍缩。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。 -请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 +请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前经头部探测校验格式、8-bit sRGB/sRGBA、尺寸和 alpha 事实;不匹配则重新生成该条目。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。 `DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。`imageHostPath` 派生规范化对象的绝对宿主路径,不检查工具执行环境。组装请求时,LLM 消费方要求当前文件系统把该宿主对象映射到其执行环境。宿主文件系统返回进程路径;没有共享挂载的远程文件系统不返回路径。映射后的路径不进入持久历史,也不进入 `RequestImageAttachment`。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts index bf83d48cf9..1b16aafb30 100644 --- a/packages/attachment/attachment-local/src/encoding.ts +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -1,4 +1,41 @@ -/** Shared lazy candidate execution for normalization and request-image encoders. */ +/** Shared quality ladder and lazy candidate execution for normalization and request-image encoders. */ + +import type { Sharp } from 'sharp' + +/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */ +export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const +/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */ +export const WEBP_ENCODING_EFFORT = 0 + +/** One ladder output carrying its complete bytes and exact facts. */ +export interface EncodedImage { + data: Uint8Array + mediaType: 'image/jpeg' | 'image/webp' + width: number + height: number +} + +async function encode(pipeline: Sharp, mediaType: EncodedImage['mediaType'], quality: number): Promise { + const encoded = mediaType === 'image/webp' + ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT }) + : pipeline.jpeg({ quality }) + const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) + return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } +} + +/** + * Build the lazy quality ladder for one prepared pipeline: WebP keeps a source + * alpha channel, everything else is JPEG. + * @param prepared - sized sRGB pipeline; cloned per candidate. + * @param hasAlpha - decoded source alpha fact selecting the codec. + * @returns encoders ordered from highest to lowest ladder quality. + */ +export function encodingLadder(prepared: Sharp, hasAlpha: boolean): Array<() => Promise> { + const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' + return IMAGE_ENCODING_QUALITIES.map(quality => ( + () => encode(prepared.clone(), mediaType, quality) + )) +} /** One encoded candidate carrying its complete bytes. */ export interface EncodedCandidate { @@ -13,7 +50,7 @@ export interface ExhaustedEncoding { /** * Execute encoding candidates in preference order and stop after the first fitting output. * @param attempts - lazy encoders ordered from preferred to fallback representation. - * @param maxBytes - positive encoded-byte cap. + * @param maxBytes - positive encoded-byte target. * @returns the first fitting candidate, otherwise the smallest completed fallback. */ export async function encodeFirstWithinLimit( @@ -37,7 +74,7 @@ export async function encodeFirstWithinLimit( /** * Whether a lazy encoding result exhausted every candidate at one size. * @param result - first fitting candidate or exhausted result. - * @returns whether every candidate exceeded the byte cap. + * @returns whether every candidate exceeded the byte target. */ export function isExhaustedEncoding( result: T | ExhaustedEncoding, diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 59467107a8..46919200eb 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -35,11 +35,15 @@ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 /** Default per-side pixel cap for one submitted image. */ export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 /** - * Default long-edge target of the stored normalized image. A larger source - * is admitted and downscaled to this edge, so admission bounds what rides - * every later model request without refusing ordinary large sources. + * Default total-pixel budget of the stored normalized image. A larger source + * is admitted and downscaled proportionally, so admission bounds what rides + * every later model request without refusing ordinary large sources; extreme + * aspect ratios keep their short-edge resolution instead of collapsing under + * a long-edge rule. */ -export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 2048 +export const DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS = 2048 * 2048 +/** Default long-edge cap of the stored normalized image, applied after the total-pixel budget. */ +export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 8192 /** Default encoded-byte target for one stored normalized image. */ export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024 /** Conservative default number of simultaneous native image transformations per store. */ @@ -61,7 +65,9 @@ export interface Config { maxImagePixels?: number /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number - /** Long-edge pixel cap of the stored provider-independent normalized image. */ + /** Total-pixel budget of the stored provider-independent normalized image. */ + normalizedImageMaxPixels?: number + /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */ normalizedImageMaxDimension?: number /** * Encoded-byte target of the stored provider-independent normalized image; @@ -142,6 +148,7 @@ export class LocalAttachmentStore extends AttachmentStore { maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), + normalizedImageMaxPixels: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS), normalizedImageMaxDimension: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION), normalizedImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_BYTES), imageCompressionConcurrency: z.number().step(1).min(1).max(MAX_IMAGE_COMPRESSION_CONCURRENCY) @@ -170,6 +177,7 @@ export class LocalAttachmentStore extends AttachmentStore { mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) this.normalizationPolicy = Object.freeze({ + maxPixels: config.normalizedImageMaxPixels ?? DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, maxDimension: config.normalizedImageMaxDimension ?? DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, maxBytes: config.normalizedImageMaxBytes ?? DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts index 4d679912a8..22db15b740 100644 --- a/packages/attachment/attachment-local/src/normalization.ts +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -3,13 +3,16 @@ import sharp, { type Sharp } from 'sharp' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' -import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' +import { encodeFirstWithinLimit, encodingLadder, isExhaustedEncoding } from './encoding.ts' +import { requestImageDimensions } from './request-image.ts' import { detectImage, encodedAlphaIsCompatible } from './image.ts' import type { DetectedImage } from './image.ts' /** Deployment-resolved policy for the persisted normalized attachment. */ export interface NormalizationPolicy { - /** Long-edge cap in pixels; larger sources are downscaled proportionally. */ + /** Total-pixel budget; larger sources are downscaled proportionally. */ + maxPixels: number + /** Long-edge cap in pixels applied after the total-pixel budget, bounding extreme aspect ratios. */ maxDimension: number /** Encoded-byte target for the quality ladder; the smallest ladder output is kept when no quality fits. */ maxBytes: number @@ -23,24 +26,6 @@ export interface NormalizedImage { height: number } -/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */ -export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const -/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */ -export const WEBP_ENCODING_EFFORT = 0 - -/** Encode one prepared pipeline and report exact output facts. */ -async function encode( - pipeline: Sharp, - mediaType: 'image/jpeg' | 'image/webp', - quality: number, -): Promise { - const encoded = mediaType === 'image/webp' - ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT }) - : pipeline.jpeg({ quality }) - const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) - return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } -} - /** * Whether bytes already satisfy the normalization requirements. * @param detected - fully decoded source facts. @@ -59,6 +44,7 @@ export function canPassThroughNormalization( && detected.depth === 'uchar' && detected.space === 'srgb' && bytes <= policy.maxBytes + && detected.width * detected.height <= policy.maxPixels && Math.max(detected.width, detected.height) <= policy.maxDimension } @@ -92,33 +78,22 @@ function preparedPipeline(data: Uint8Array, width: number, height: number): Shar .resize({ width, height, fit: 'inside', withoutEnlargement: true }) } -/** Dimensions after the long edge is capped without changing aspect ratio. */ -function initialDimensions(detected: DetectedImage, maxDimension: number): { width: number; height: number } { - const scale = Math.min(1, maxDimension / Math.max(detected.width, detected.height)) +/** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */ +function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } { + const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels) + const longEdge = Math.max(budgeted.width, budgeted.height) + if (longEdge <= policy.maxDimension) return budgeted + const scale = policy.maxDimension / longEdge return { - width: Math.max(1, Math.round(detected.width * scale)), - height: Math.max(1, Math.round(detected.height * scale)), + width: Math.max(1, Math.floor(budgeted.width * scale)), + height: Math.max(1, Math.floor(budgeted.height * scale)), } } -/** Lazy quality ladder: WebP keeps a source alpha channel, everything else is JPEG. */ -function encodingAttempts( - data: Uint8Array, - width: number, - height: number, - hasAlpha: boolean, -): Array<() => Promise> { - const prepared = preparedPipeline(data, width, height) - const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' - return IMAGE_ENCODING_QUALITIES.map(quality => ( - () => encode(prepared.clone(), mediaType, quality) - )) -} - /** * Produce the persisted provider-independent normalized version of one fully decoded source. * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, - * and inside both normalization limits. Re-encoding never removes transparency. When every + * and inside every normalization limit. Re-encoding never removes transparency. When every * ladder quality exceeds the byte target, the smallest ladder output is kept; provider byte * caps stay enforced at the route that transmits the bytes. * @param data - complete admitted source bytes. @@ -135,9 +110,9 @@ export async function normalizeImage( return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } } try { - const { width, height } = initialDimensions(detected, policy.maxDimension) + const { width, height } = initialDimensions(detected, policy) const encoded = await encodeFirstWithinLimit( - encodingAttempts(data, width, height, detected.hasAlpha), + encodingLadder(preparedPipeline(data, width, height), detected.hasAlpha), policy.maxBytes, ) const chosen = isExhaustedEncoding(encoded) ? encoded.smallest : encoded diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 1764eded3a..598e79a893 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -12,8 +12,13 @@ import type { RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { IMAGE_ENCODING_QUALITIES, WEBP_ENCODING_EFFORT } from './normalization.ts' -import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts' +import { + IMAGE_ENCODING_QUALITIES, + WEBP_ENCODING_EFFORT, + encodeFirstWithinLimit, + encodingLadder, + isExhaustedEncoding, +} from './encoding.ts' import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ @@ -116,31 +121,6 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp { return sharp(attachment.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') } -async function encoded( - image: Sharp, - mediaType: 'image/jpeg' | 'image/webp', - quality: number, -): Promise { - const output = mediaType === 'image/webp' - ? image.webp({ quality, effort: WEBP_ENCODING_EFFORT }) - : image.jpeg({ quality }) - const { data, info } = await output.toBuffer({ resolveWithObject: true }) - return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } -} - -function encodingAttempts( - attachment: StoredImageAttachment, - width: number, - height: number, - hasAlpha: boolean, -): Array<() => Promise> { - const prepared = pipeline(attachment, width, height) - const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' - return IMAGE_ENCODING_QUALITIES.map(quality => ( - () => encoded(prepared.clone(), mediaType, quality) - )) -} - async function createRequestImage( attachment: StoredImageAttachment, policy: ImageRequestPolicy, @@ -158,7 +138,7 @@ async function createRequestImage( } } const encodedVersion = await encodeFirstWithinLimit( - encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha), + encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha), policy.maxBytes, ) return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index da0cef1400..4d9d9f350b 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -9,6 +9,7 @@ import sharp from 'sharp' import LocalAttachmentStore, { DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, DEFAULT_IMAGE_COMPRESSION_CONCURRENCY, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_IMAGE_DIMENSION, @@ -34,6 +35,7 @@ describe('local attachment service', () => { mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) expect(service.normalizationPolicy).toEqual({ + maxPixels: DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, maxDimension: DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, }) diff --git a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts index 4e39223f24..4a9faf6d7d 100644 --- a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts @@ -29,7 +29,7 @@ describe('normalization verification', () => { const detected = await detectImage(data) control.mismatch = true - await expect(normalizeImage(data, detected, { maxDimension: 5, maxBytes: 4 * 1024 * 1024 })) + await expect(normalizeImage(data, detected, { maxPixels: 2048 * 2048, maxDimension: 5, maxBytes: 4 * 1024 * 1024 })) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', diff --git a/packages/attachment/attachment-local/tests/normalization.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts index 6403e1a0b4..d60989075d 100644 --- a/packages/attachment/attachment-local/tests/normalization.spec.ts +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -4,7 +4,7 @@ import { canPassThroughNormalization, normalizeImage } from '../src/normalizatio import type { NormalizationPolicy } from '../src/normalization.ts' import { detectImage } from '../src/image.ts' -const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 4 * 1024 * 1024 } /** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */ function noisePixels(width: number, height: number): Uint8Array { @@ -34,13 +34,14 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | describe('canPassThroughNormalization', () => { it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => { const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false } - expect(canPassThroughNormalization({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 8192, height: 4, ...clean }, 100, POLICY)).toBe(true) expect(canPassThroughNormalization({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) - expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 2048, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 8193, height: 4, ...clean }, 100, POLICY)).toBe(false) expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) }) }) @@ -76,19 +77,20 @@ describe('normalizeImage', () => { const data = await flatImage(10, 6, 'png') const detected = await detectImage(data) - const normalized = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3 }) await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) - const again = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const again = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }) expect(again.data).toEqual(normalized.data) }) it('re-encodes the normalized output of a resize into itself (idempotence)', async () => { const data = await flatImage(10, 6, 'png') - const first = await normalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const budget = { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes } + const first = await normalizeImage(data, await detectImage(data), budget) - const second = await normalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes }) + const second = await normalizeImage(first.data, await detectImage(first.data), budget) expect(second.data).toBe(first.data) }) @@ -109,7 +111,7 @@ describe('normalizeImage', () => { const data = await flatImage(9, 5, 'webp', true) const detected = await detectImage(data) - const normalized = await normalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 4, maxBytes: POLICY.maxBytes }) expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 4, height: 2 }) await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) @@ -132,6 +134,7 @@ describe('normalizeImage', () => { await expect(detectImage(data)).resolves.toMatchObject({ hasAlpha: true }) const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: 32, maxBytes: POLICY.maxBytes, }) @@ -154,7 +157,9 @@ describe('normalizeImage', () => { } const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer()) - const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 }) + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: side, maxBytes: 1_024, + }) expect(normalized.data.byteLength).toBeGreaterThan(1_024) expect(normalized).toMatchObject({ mediaType: 'image/webp', width: side, height: side }) @@ -165,7 +170,7 @@ describe('normalizeImage', () => { const data = await noiseImage(64, 32, 'jpeg') const detected = await detectImage(data) - const normalized = await normalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes }) + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 32, maxBytes: POLICY.maxBytes }) expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) }) @@ -183,7 +188,7 @@ describe('normalizeImage', () => { } const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer()) const detected = await detectImage(data) - const budget = { maxDimension: 128, maxBytes: POLICY.maxBytes } + const budget = { maxPixels: POLICY.maxPixels, maxDimension: 128, maxBytes: POLICY.maxBytes } const normalized = await normalizeImage(data, detected, budget) @@ -195,7 +200,9 @@ describe('normalizeImage', () => { it('keeps the smallest opaque ladder output above an unreachable byte target', async () => { const data = await noiseImage(64, 64, 'png') - const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 }) + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: 2048, maxBytes: 512, + }) expect(normalized.data.byteLength).toBeGreaterThan(512) expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 64, height: 64 }) @@ -262,6 +269,26 @@ describe('normalizeImage', () => { }) }) + it('downscales by total pixels so an extreme aspect ratio keeps its short edge', async () => { + const data = await flatImage(10, 40, 'png') + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: 100, maxDimension: 8192, maxBytes: POLICY.maxBytes, + }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 20 }) + }) + + it('caps the long edge after the total-pixel budget', async () => { + const data = await flatImage(4, 64, 'png') + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: 10_000, maxDimension: 16, maxBytes: POLICY.maxBytes, + }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 1, height: 16 }) + }) + it('keeps an antialiased text screenshot readable on the JPEG ladder', async () => { const source = new Uint8Array(await sharp(Buffer.from(` @@ -271,6 +298,7 @@ describe('normalizeImage', () => { `)).removeAlpha().png().toBuffer()) const normalized = await normalizeImage(source, await detectImage(source), { + maxPixels: POLICY.maxPixels, maxDimension: 512, maxBytes: POLICY.maxBytes, }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index d1721ceacd..3ff58eb2da 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -39,7 +39,7 @@ const PNG = Uint8Array.from(Buffer.from( 'base64', )) -const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 } +const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 1024 * 1024 } const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, @@ -157,7 +157,7 @@ describe('local attachment store', () => { const saved = await saveImageFile(storageRoot, { data: oversized, mediaType: 'image/png', name: 'big.png', - }, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 }) + }, { ...LIMITS, maxImagePixels: 64 }, { maxPixels: POLICY.maxPixels, maxDimension: 2, maxBytes: 1024 * 1024 }) expect(saved).toMatchObject({ mediaType: 'image/jpeg', diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index ccfc3a3f98..3e8add393f 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -121,7 +121,7 @@ export abstract class AttachmentStore extends Service { /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel and encoded-byte budget. + * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0798c8f31c..eea3a05e2b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -459,7 +459,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', - parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel and encoded-byte budget.' }, { name: 'signal', description: 'optional cancellation.' }], + parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', }, ], diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 5aaad0eff0..92761bb840 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -150,7 +150,7 @@ export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600 export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000 /** Total-pixel budget matching provider low-detail image input. */ export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512 -/** Encoded-byte cap for one deterministic model-request image. */ +/** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Deterministic raw-byte removal step. */ export const DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM = 64 * 1024 * 1024 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6121212098..034878a1ce 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 1ae0e641f7ffaebc1c2c1060e143c72a40631396 -README.zh.md: 17bf059ea9bb3fbbb80cadc0c9eefed76af26abd +README.md: 31e40e5f0fa3c1e7e0ae0df05aa0a76d54d120b0 +README.zh.md: cd40804ce5908aebd0c35011ad1d56879834164d diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 1ae0e641f7..31e40e5f0f 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -123,7 +123,7 @@ A model that carries reasoning metadata — from the installed catalog or from i A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen maximum-size 1MiB versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64. Its descriptor exposes the attachment id and actual request-image dimensions, plus a normalized-object path only when the attachment provider exposes a host object and the current filesystem maps it into the tool execution world. The path is resolved separately from the request version and its `variantId`. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, `requestImagePixelBudget`, `requestImageMaxBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Every image route derives a deterministic request version from the provider-independent normalized attachment under `requestImagePixelBudget` (default 2048 by 2048 total pixels) and `requestImageMaxBytes` (default 1MiB raw bytes). Before reading attachments, `maxRequestImageBytes` applies to conservative request-version upper bounds and replaces the oldest over-budget images with per-image text; exact base64 lengths are checked again after retained versions are generated. The 20MiB default can retain fifteen 1MiB-target versions after base64 expansion while leaving request-body headroom. The same version feeds inline base64. Its descriptor exposes the attachment id and actual request-image dimensions, plus a normalized-object path only when the attachment provider exposes a host object and the current filesystem maps it into the tool execution world. The path is resolved separately from the request version and its `variantId`. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 17bf059ea9..cd40804ce5 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -124,7 +124,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 上限生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64。对应描述会公开附件 ID 和实际请求图片尺寸;只有附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,描述才会加入规范化对象路径。该路径独立于请求版本及其 `variantId`。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes`、`requestImagePixelBudget`、`requestImageMaxBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。每条图片路由从提供方无关的规范化附件派生确定性请求版本,受 `requestImagePixelBudget`(默认总像素 2048×2048)和 `requestImageMaxBytes`(默认原始字节 1MiB)约束。读取附件前,`maxRequestImageBytes` 先按请求版本的保守上界把超预算的最旧图片替换为逐图文本;保留版本生成后再用确切 base64 长度检查。20MiB 默认值可保留十五个按 1MiB 目标生成的请求版本,并为请求正文留下余量。同一版本用于内联 base64。对应描述会公开附件 ID 和实际请求图片尺寸;只有附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,描述才会加入规范化对象路径。该路径独立于请求版本及其 `variantId`。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b01b3521ff..e5a7e608b9 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -54,7 +54,7 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 /** Default total-pixel budget preserves the complete 2048px normalized attachment. */ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048 -/** Default raw encoded-byte cap before inline base64 expansion. */ +/** Default raw encoded-byte target before inline base64 expansion; the smallest quality-ladder output is used when no quality fits. */ export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Context capacity assumed for a model neither configuration nor the catalog sizes. */ @@ -193,7 +193,7 @@ export interface ResolvedPiAiProviderProfile maxRequestImageBytes: number /** Positive total-pixel request-version budget after defaulting. */ requestImagePixelBudget: number - /** Positive raw request-version byte cap after defaulting. */ + /** Positive raw request-version byte target after defaulting; the smallest quality-ladder output is used when no quality fits. */ requestImageMaxBytes: number /** Immutable retry policy captured with this provider route. */ retryPolicy: ResolvedRetryPolicy From cfacca1b07144c1b2488d624c17de30f0279ddd2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 17:15:59 +0800 Subject: [PATCH 19/42] test(attachment): pin the assembled image re-encoding path in a keyless snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-image-reencode lane feeds a 16-bit gradient PNG through the shipped app: pass-through is impossible, the master converts down the opaque JPEG ladder, and the 640,000-pixel request budget re-encodes a downscaled request version — the projection the byte-identical tiny fixtures never exercised. --- examples/acp-agent/tests/acp.snapshot.ts | 12 ++++++++ .../snapshots/read-image-reencode/input.json | 14 +++++++++ .../read-image-reencode/session.jsonl | 29 ++++++++++++++++++ .../read-image-reencode/stdout.expected.jsonl | 6 ++++ .../workspace/gradient.png | Bin 0 -> 22565 bytes 5 files changed, 61 insertions(+) create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/input.json create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-reencode/workspace/gradient.png diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3e8f3aa982..f6bd6124e7 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -262,6 +262,18 @@ const SCENARIOS: Scenario[] = [ headerClass: 'image', configPath: IMAGE_CONFIG, }, + // Authored keyless replay of the re-encoding path: the 900x1200 16-bit + // gradient PNG cannot pass through, so the master converts down the opaque + // JPEG ladder and the 640,000-pixel request budget re-encodes a downscaled + // request version — the assembled projection the tiny byte-identical + // fixtures above never exercise. + { + name: 'read-image-reencode', + hasModelTurn: true, + recorded: false, + headerClass: 'image', + configPath: IMAGE_CONFIG, + }, { name: 'inline-image-prompt', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/input.json b/examples/acp-agent/tests/snapshots/read-image-reencode/input.json new file mode 100644 index 0000000000..f08307ffed --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-reencode/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image to look at gradient.png in the current directory, then reply with exactly the single word DONE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl new file mode 100644 index 0000000000..c6a2466090 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-reencode/session.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333334","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at gradient.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"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":"user/message","data":{"content":[{"type":"text","text":"Use read_image to look at gradient.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Use read_image to look at","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"read-image-reencode-call","name":"read_image","arguments":"{\"file_path\":\"gradient.png\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-reencode-call"},"content":[{"type":"tool-result","toolCallId":"read-image-reencode-call","content":[{"type":"text","text":"{{cwd}}/gradient.png\nimage\n\nimage/jpeg image, 840x840 px, 10162 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:a9725ac66134512fc8e8971cb0356f9d217529493073d361724ca87c9c7aa968","mediaType":"image/jpeg","bytes":10162,"width":840,"height":840,"name":"gradient.png"}}],"isError":false}],"role":"user","id":"d52141fc-3f8a-42f9-bff0-a44f5a02579a"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl new file mode 100644 index 0000000000..a1153f758c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-reencode/stdout.expected.jsonl @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"read-image-reencode-call","title":"read_image","kind":"other","status":"in_progress","rawInput":{"file_path":"gradient.png"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-image-reencode-call","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/gradient.png\nimage\n\nimage/jpeg image, 840x840 px, 10162 bytes\n"}},{"type":"content","content":{"type":"image","data":"/9j/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCANIA0gDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECBv/EABYQAQEBAAAAAAAAAAAAAAAAAAARYf/EABoBAQEBAQEBAQAAAAAAAAAAAAADAQIEBgj/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEhH/2gAMAwEAAhEDEQA/AO5pUHz78jytKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStEAlBmldLy0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0oS0M0CQZG4vLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEpSoNxeVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVogYSgg3F8UQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMUQMMZpQavJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJSgElKASUoBJQAlkSldPRKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUCUEG4tKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKiBhKCUrceiVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEpTCVEoYSgyNXloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAloZAkpUul10vK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0qXS6ErSpdLoStKl0uhK0S6BLIlK16JUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSgSlKg3FpWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWlQMJWiBhKUqUrrHolaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaVKUwlaJQwlBmlavLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzShLQzQJSlQavK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0QCUEpXS8qJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJQJSlQbi8rSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErSoGErRAwlmlSlbi8rSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErSpSmErRKGEoINeiVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlRAJUQCVEAlKVB0tK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0qAStKgErSoBK0QCUGaVuPRLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzSmEtDNKYS0M0phLQzQwkGRuLy0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhLQyGEtDIYS0MhhKANxeQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwkAMJADCQAwlkQavKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASlKg6XlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaIBLIUrceiQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKUwkClMJApTCQKGEoMjcXxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDGhkMMaGQwxoZDDEpUpWrytKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStEoEoJSul5USlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSgSlKg1eVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVogEsiUrcXlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKUwlRKGEpSoOsXxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaVAwxaIGGJSpStXlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaVKUJWlSlCVpUpQlaJQJQSla9EqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJShKiUoSolKEqJQJZpQbi0lKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElKBhJSgYSUoGElAMJZEpXWPRKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUphKiUMJQQbi+KIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGKIGGJSoNXlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaVAJWlQCVpUAlaIBKDI6eiWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCWhkCSlZGrS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1SsgS1RkCUEpW49EqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJSmEqJQwlLpdQbi8rdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdLqBhK3S6gYSt0uoGErdEDCWQpWryBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBShIFKEgUoSBQJZEHS8qIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKiASogEqIBKUqXS63F5WlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWlS6XTCVpUul0wlaVLpdMJWiXQwlBKVuLyolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolKYSolDCUpWR0vjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVKyBjVGQMSlSlavK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0qUoStKlKErSpShK0SgSglK16JUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSlCVEpQlRKUJUSgSlKg6xaVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVpUDCVogYSgzStx6JaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaUwloZpTCWhmlMJaGaGEgDV8AAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAx//2Q==","mimeType":"image/jpeg"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-reencode/workspace/gradient.png b/examples/acp-agent/tests/snapshots/read-image-reencode/workspace/gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..bfb45f69317acbad7fc0e077e0d2143b92b23561 GIT binary patch literal 22565 zcmb_k3sltQ630irLa~RDLZDgK2Z)NvLl7}7BtsAdBoNlTNXGJzz3j39i-hGZuV^BW zfvhH_hQJ1jAc&YL^4c{E1q9t5>}C>fY_e>wc)zdm^l*lI#&gfflLy@Y&dxXUo8SCq zzJ14Uh1b|Ilg5~tnT_>cw$$IuY}hjXXZ%q1lcy(aQL+Dxj$0NiVn6(z{~2;D$v?`> zY_gg6(#5M2^SZBGH{2EN9ib~sXjr|<=XT8_ZSVLmow9Vw?&o)<_|&fm%ktaecggRi zk9_?5X6-L6X?eNq=5qF5iR(4}CQW$%T4x(cvBB6;rTo@CcCGf7x1;vo&v1C^e|`JkUgv&JpdIxWvwr{i$KBq+Ho14}!B-Wmvn!lFWi(($;t|-qL}6QI zse80;#r8<=cH0Yc8wA~dHbNgf+HVpfj+IEk~ub4FQ=M5h+}t~R#6?h z3UTZz?%2b6TdH-5pCFEXojDeX@`DZ|st$J9I)b^ov9)35OJE-6Z%~Ma19+4l*jHeG zYW`grG24cu@0|)Hz|Qjhb9`0T$XL(`M}+%HjsAz+g~A$7Vyx&~@q0hAt7IPa z^dped4?r_c9lY&yPn*6(Lxi%#awMk3ydF(U-2cST8M+dTGh) z<-=Ne6Y-=3@aMhg^mrglg{tG`ww|1xnn!#e-P1@!e8?l>C({9HM1%=p%fmw1NzJae z-2fsgPVuTghtaIMfT0-zJIfjDEZ5q?K0h@65JLVABgwXrC0n)qDLK`(j)-eZxoeH4 zlVsP%Ag&E#t}Qu#(FE%ck2icFP3Q}I% z3qI0siXTGgUP2twz#S4Gn+85^=i5aHk5LDVaP#^TC%Z?@)d3N{4W%a2St?s^w1P=* z7Xt;P1F=8#WOv|sGM)t@T*{8aMWI#&7mC%RRwK#ZjwHzmmL!2qQ>g{mZW8Q>3AiJU zR4_+;V4)%=iDDO=bZZVX%fm9aQmI+kdL&0yu^h2ejf7bx>ewR)ngu#ACKAx=NhQ5A z;fNJvu?#>u!a|m70ops%*7(TlupN|Pj#z$-Sst8n)eS7b`<~y2v>*vE z6e!-n$$E=rdgr7{Do6;%BJcDfd#59=H}i?0Bk)0K&fmQo|w&;ZacxDI)e7GpRN1(p=abH#jh!Fm9sS zcLE99PCVguj1!mSN?`IZ2(?}S$A#_$FK#I@Dk}9q2?Jwd3Xe1A*j#GdE`;qr?l!#R~LXlf%k5;R!D?d@d&$PnnU$N3gQPD^Mi0s>>ld#h9S>e zjQe#g^J{Lrh#JeeNVk>i>6GW(Of9_o;rMD*u)^!qu$@X7tR}*gVLOv^5bh*A`egT2 z_t^J|9#=QRIjwGHw68Zil+4qgfKTwkKXz`{CQ$3H5Mc~XoH6*6{`?*!p$PpISrM6` zxgXhr+DufAF>u2tQvs?wd7(u+B2qa8n~QUmQ81IlCGgg-U@9qmg%POO<`!yP#LkBS zJ$h)HFl;^*3^u?=FDS#|Ja^WJs%CvU6~vkm*wOIb;;d9t!&`6|Vt;k?a7|!6(tqGog3br!M#jUAC zh2VL~Y<*GFYA;}{0gZcIToG?kF~xeW|Fx_r*hV{53wDK5}%I4G^B`qd3#PkV9p)XmtFSef(Jelxtx zbnD(Oz&%Y5pJR!U({$t5>7d_6#kB5MVN4UxV9{ad`i6*UgN((hl-tO}rUfQ8-Hg~E z0;AwE8zP^L1HS{6WCW%vvewJ*jOYCmTW_`ZyG)^csoDmX?A9nFlYNH%%Q>NV|;? zh!mVabTb0c*BA`97-Q0qVZ@vQsl3q| z>9ohO1VHBBak^I(w=bBQsE)NoL|WUKEY~cwyWw1W%M)oCwjvR>m7mj2X)o2htq93m zg0lzf(kHt;cKJqv7>7?|laXm`GM~nd+bjQoSR&MZa7@I##!7^Bb|zdRCScohTM-&{ zhBw)|N?3fc4pU@x>7?Hvn=dY#_QXbfyZ~6@8-7v0uLz!RsC6uZm#!dV#b-#$tz;?Z zrSw&T8H|h4aRso*=Tgd!E2H5jBGMi}kai_QMnr|mc*R`orShe+^|+&m;e4Q(QC~;h zAIU(*3U!=1e5zkhExy)KFw@Oo(=`Npbl(dMU;GC~3VaW92LkCkI7iu}ub^_C3}hj- zj*Xi_GAbB$R=ex%pGbi4^4mxD$b?F-9^Uh(qNS)?Ws%Mj%;1ozc!cHE+(*x|C zL@!g7o;n1D>o^KOrvIMmT90*bOnC6*tAaa!l%+c_h7IJ4;qLt5Py-Jz(DzzZzAv%MS z>kOjOAZmGVg~LnDSCCyL4k_RUvi1of`(=-7jtp#A`a(4!dk$yZTLAo%u%7lbCL4&@ zI+3((Dm6RYVf>BbYZQ5lBvg$|os2w>uOo!+wD>3ZdGQ7<6cP6vrlZ6(?lfzPKkJAl zs*;1KvzD4W)Tp&Yc<#fTjD1*ngPLuY2*_^3TjEeAs5>Zc!!0K+qB|fWx+NFUEwv8R zd_#J9HV!lTi>YO;A6c*L_vhi|C?WUjk0Rmf&7Ozob9@^+M}L)yzq66S?riSYAWafg z|3}1Qb}dY_QQSnN3l+$gMCc%N;Uc4tEyf-y&8a~aIcxYrMtCE6;jjj|yi~(4FNMq9 zsI58zIP9i^rqmur^PCC7h)Vk_k=H@8D!Bb1E9s3a%A7S~YPKbxfmuWH0mhzc_DHCr zWHQ1+lFRt><{yVw=J6eOL_98to_++r#n69=p}Dx6dX2CjxoOnTZyF^>#w-MnYLK-( z2|}MFoaOdblk09g0z6V2cp6!SmuRn0iDZ87S-365ntcnzJA-N zp;qJ=B%z&oLT_)IPL1Lh5olYFLz@+YHXqR$vgL>r<;*V|G)u{MxUo%K4eRA9BVy)I z@nIH{&)K*bb(~#~vE6Wo$}Vk@e#4gQ>P7nPR8a0fSXvCfZe!aumKw8*kds=(CyrM% zZ;6@>!3 z#kZ^Q=E?OTI-V zn2V4YTf_=TwmkAJY5|rbq)g6ZY__JCTHs9Z>cl|FTDRaag=5mqI+SJSq)soUT+n~8 z&Sv=V;aj$hKev9RVammAv0onVDZWvd+LDl;WNbVl zVZfCdLde(Ltl>oCnCU&LbqFuG#H~y5kbxk7gK_&y+A?E5csVt(@7!_6RZqd&0vC|5 zRMeP@t&9ta$Ra@08zF~mO)~ke;m0u5XyxBKQ)J4f`WE@3m~!S@WXWGRE4{PNltArU zqv1-ZSj!5zn~{pwf;w1W=;mJSF-fV`twpR0K?#>YGO>F==}RTeFt|>RUb3!Fvx5pK z-N*#tB!lWUl?Bzd+puwhQ;fxq(-^4MS>NC9|JviH2kKgz(t?o3@tdZh%@$OrRv=&##P>lK+@x+&Blyk6yvWv7 zNzUn>H)@=zbnzIBTooL-a;tNRlqL?@xr*V^vbcu&#eB7xw2ZIO##Nb%B~mvcQB)(} zlG*WVhnyTmMXe+RwfL=23vXgMs7;5@D3R@K^etNakXOkehxL=#_x_rBzp!Fy@$-T= F{|n3MEMEWs literal 0 HcmV?d00001 From 1d4dcf3b576a913ca3715167aba389d87c03dd3c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:55:26 +0800 Subject: [PATCH 20/42] refactor(python): remove the private direct-config carrier Delete @deepseek-ai/dsh-sdk-python-runtime and its packaged-bin entry now that Python uses the repository's dsh application launcher. Remove the package's project reference, Knip entry, workspace-policy exception, executable allowlist entry, and README-model classification together so no tooling preserves the old exception. This commit is deliberately mechanical: it removes the obsolete package and gate accommodations without introducing the replacement launch behavior. The following commits add the packaged dsh runtime and Python profile API, keeping the architecture change separate from deletion noise. --- knip.json | 5 -- packages/sdk/python-runtime/README.i18n.yaml | 6 --- packages/sdk/python-runtime/README.md | 34 ------------ packages/sdk/python-runtime/README.zh.md | 34 ------------ packages/sdk/python-runtime/package.json | 48 ----------------- packages/sdk/python-runtime/src/index.ts | 10 ---- packages/sdk/python-runtime/src/invariant.ts | 30 ----------- .../sdk/python-runtime/src/packaged-bin.ts | 12 ----- packages/sdk/python-runtime/src/runner.ts | 54 ------------------- packages/sdk/python-runtime/tsconfig.json | 24 --------- packages/sdk/python-runtime/tsdown.config.ts | 17 ------ scripts/check-workspace-constraints.spec.ts | 24 --------- scripts/check-workspace-constraints.ts | 12 ----- .../verify-application-entrypoints.spec.ts | 10 ++-- scripts/verify-application-entrypoints.ts | 7 ++- .../verify-package-readme-model-experience.ts | 1 - tsconfig.host.json | 1 - 17 files changed, 9 insertions(+), 320 deletions(-) delete mode 100644 packages/sdk/python-runtime/README.i18n.yaml delete mode 100644 packages/sdk/python-runtime/README.md delete mode 100644 packages/sdk/python-runtime/README.zh.md delete mode 100644 packages/sdk/python-runtime/package.json delete mode 100644 packages/sdk/python-runtime/src/index.ts delete mode 100644 packages/sdk/python-runtime/src/invariant.ts delete mode 100644 packages/sdk/python-runtime/src/packaged-bin.ts delete mode 100644 packages/sdk/python-runtime/src/runner.ts delete mode 100644 packages/sdk/python-runtime/tsconfig.json delete mode 100644 packages/sdk/python-runtime/tsdown.config.ts diff --git a/knip.json b/knip.json index b6c4e33254..6e03ebf7da 100644 --- a/knip.json +++ b/knip.json @@ -550,11 +550,6 @@ "zod" ] }, - "packages/sdk/python-runtime": { - "project": [ - "src/**/*.ts" - ] - }, "packages/subagent/subagent-spawn-in-process": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/sdk/python-runtime/README.i18n.yaml b/packages/sdk/python-runtime/README.i18n.yaml deleted file mode 100644 index d2b2859c7c..0000000000 --- a/packages/sdk/python-runtime/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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/sdk/python-runtime/README.md -README.md: 291ad3edaff8182007079de8e41f33e91546e5d7 -README.zh.md: 54001ccfffb356db290d7c7b38db41070d8dd661 diff --git a/packages/sdk/python-runtime/README.md b/packages/sdk/python-runtime/README.md deleted file mode 100644 index 291ad3edaf..0000000000 --- a/packages/sdk/python-runtime/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# @deepseek-ai/dsh-sdk-python-runtime - -English | [中文](README.zh.md) - -Private direct-config carrier for the temporarily unchanged Python SDK runtime. Its [`jsonrpc`](../server/README.md) entry serves SDK clients over newline-delimited stdio, while an external `cordis.yml` composes the spine, backends, and serving plugin. This npm package exposes no public bin and is not published; the Python SDK's existing `dsh-jsonrpc-agent-pkg--` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) packages `lib/packaged-bin.js` from the closed deploy tree. Bare plugins resolve from that tree, while relative plugins remain configuration-relative. - -## Config discovery - -The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the packaged entry prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../../boot/app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`. - -A config without `dsh-sdk-jsonrpc-server` is valid and serves nothing; the carrier does not designate a server plugin. - -## Exit lifecycle - -stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exits 130 after the same disposal. EOF may cut off an in-flight turn as documented in the [distribution Agent Note](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). The `jsonrpc` plugin owns response-before-exit protocol shutdown; both paths are idempotent and safe to race. - -## stdout is the protocol - -stdout carries only JSON-RPC frames. The carrier and boot guards diagnose on stderr, and the config must omit stdout loggers. - -## Model Experience - -Indirectly, through the plugins loaded from the external `cordis.yml`, which own every model-bound prompt, schema, message, and result; this carrier adds none of its own. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **Temporary direct-config exception** — this private carrier remains outside `dsh --profile sdk` only to preserve the current Python executable and wheel behavior; the later Python runtime migration deletes it and then renames the executable family. -- **The carrier cannot prove that the config serves JSON-RPC** — a valid config with no `dsh-sdk-jsonrpc-server` entry boots successfully and serves nothing. -- **No built-in or default config exists** — every launch must provide `DSH_CORDIS_CONFIG` or a positional path, and deployment owns the complete plugin tree and stdout discipline. -- **stdin EOF cuts off in-flight work** — client disappearance disposes the root immediately; callers that need orderly completion use the protocol-level `shutdown` request. diff --git a/packages/sdk/python-runtime/README.zh.md b/packages/sdk/python-runtime/README.zh.md deleted file mode 100644 index 54001ccfff..0000000000 --- a/packages/sdk/python-runtime/README.zh.md +++ /dev/null @@ -1,34 +0,0 @@ -# @deepseek-ai/dsh-sdk-python-runtime - -[English](README.md) | 中文 - -这是为暂时保持不变的 Python SDK 运行时提供的私有直读配置载体。其 [`jsonrpc`](../server/README.zh.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务,外部 `cordis.yml` 则负责组合主干、后端和服务插件。该 npm 包不公开 bin,也不会发布;Python SDK 既有的 `dsh-jsonrpc-agent-pkg--` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)从封闭部署树打包 `lib/packaged-bin.js`。裸插件从该树解析,相对插件仍以配置目录为基准。 - -## 配置发现 - -第一个非空通道生效:先 `$DSH_CORDIS_CONFIG`,再位置参数 `argv[2]`。如果二者都没有指向现有文件,打包入口会向 stderr 打印单行用法并以 1 退出;没有工作目录回退或内置回退。[`dsh-app-boot`](../../boot/app-boot/README.zh.md) 会使插件加载失败成为致命错误。此协议不使用 `DSH_SNAPSHOT`。 - -不含 `dsh-sdk-jsonrpc-server` 的配置仍然有效,只是不提供任何服务;该载体不会指定服务器插件。 - -## 退出生命周期 - -stdin EOF 和 `SIGTERM` 会 dispose(释放资源)根上下文,等待完全停稳后以 0 退出;`SIGINT` 完成同样的 dispose 后以 130 退出。EOF 可能按[分发 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md) 所述截断正在处理的轮次。`jsonrpc` 插件拥有先响应再退出的协议关闭流程;两条路径均幂等,即使发生竞态也安全。 - -## stdout 是协议 - -stdout 只承载 JSON-RPC 帧。该载体和启动守卫在 stderr 上输出诊断,配置必须省略 stdout logger。 - -## 模型体验 - -模型体验由外部 `cordis.yml` 加载的插件间接提供;这些插件负责所有面向模型的提示词、schema、消息和结果,该载体不添加任何内容。 - -#### KV Cache 影响 - -不会直接失效;由上述消费方负责请求前缀的任何变更。 - -## 已知限制与暂缓事项 - -- **临时直读配置例外**:为了保持当前 Python 可执行文件与 wheel 包行为不变,该私有载体暂时不经过 `dsh --profile sdk`;后续 Python 运行时迁移会删除它,之后再重命名可执行文件族。 -- **载体无法证明配置提供 JSON-RPC 服务**:不含 `dsh-sdk-jsonrpc-server` 条目的有效配置也能成功启动,但不会提供任何服务。 -- **不存在内置或默认配置**:每次启动都必须提供 `DSH_CORDIS_CONFIG` 或位置路径;部署方负责完整的插件树和 stdout 纪律。 -- **stdin EOF 会截断正在处理的工作**:客户端消失时立即释放根上下文;需要有序完成的调用方应使用协议级 `shutdown` 请求。 diff --git a/packages/sdk/python-runtime/package.json b/packages/sdk/python-runtime/package.json deleted file mode 100644 index cdae65348f..0000000000 --- a/packages/sdk/python-runtime/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-sdk-python-runtime", - "description": "Private direct-config runtime carrier for the temporarily unchanged Python SDK", - "version": "0.1.1-rc.2", - "private": true, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/sdk/python-runtime" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./packaged-bin": { - "types": "./lib/types/packaged-bin.d.ts", - "default": "./lib/packaged-bin.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/packaged-bin.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "dependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - } -} diff --git a/packages/sdk/python-runtime/src/index.ts b/packages/sdk/python-runtime/src/index.ts deleted file mode 100644 index 74dabd87bf..0000000000 --- a/packages/sdk/python-runtime/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Private Python SDK runtime carrier: its packaged entry discovers an external - * `cordis.yml` and owns process exit. This module exports no composition plugin; - * the config chooses whether to load the - * {@link @deepseek-ai/dsh-sdk-jsonrpc-server} serving plugin. - * - * @module @deepseek-ai/dsh-sdk-python-runtime - */ - -export {} diff --git a/packages/sdk/python-runtime/src/invariant.ts b/packages/sdk/python-runtime/src/invariant.ts deleted file mode 100644 index 79bb5ec1f6..0000000000 --- a/packages/sdk/python-runtime/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-python-runtime`. - * @module @deepseek-ai/dsh-sdk-python-runtime/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-python-runtime' - -/** Cordis companion plugin name. */ -export const name = 'sdk-python-runtime-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this composition package owns no independent event stream or mutable data; - * Loader and built-entry tests cover its wiring. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/sdk/python-runtime/src/packaged-bin.ts b/packages/sdk/python-runtime/src/packaged-bin.ts deleted file mode 100644 index 1c5e5c3e92..0000000000 --- a/packages/sdk/python-runtime/src/packaged-bin.ts +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env node -/** - * Closed-runtime JSON-RPC agent bin. Bare plugins resolve from the installed - * runtime closure while relative plugins remain configuration-relative. - * - * @module @deepseek-ai/dsh-sdk-python-runtime/packaged-bin - */ - -import { runPythonSdkRuntime } from './runner.ts' - -/* v8 ignore next -- exercised through the built Python runtime carriers */ -await runPythonSdkRuntime(import.meta.url) diff --git a/packages/sdk/python-runtime/src/runner.ts b/packages/sdk/python-runtime/src/runner.ts deleted file mode 100644 index 9850c64a0b..0000000000 --- a/packages/sdk/python-runtime/src/runner.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Process lifecycle for the Python SDK's closed direct-config runtime. - * - * @module @deepseek-ai/dsh-sdk-python-runtime/runner - */ - -import { existsSync } from 'node:fs' -import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' - -/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ -const NAME = 'dsh-jsonrpc-agent' - -/** - * Boot the explicitly selected external configuration and own process exit. - * @param bareModuleBaseUrl - installed-runtime base for bare plugins. - * @returns after process handlers are installed; process lifetime then belongs - * to stdin and signal events. - */ -export async function runPythonSdkRuntime(bareModuleBaseUrl: string): Promise { - installFailLoud(NAME) - loadEnv(NAME) - - // Env wins over argv; empty values are absent. External config defines the deployment. - const fromEnv = process.env['DSH_CORDIS_CONFIG'] - const fromArgv = process.argv[2] - const requested = fromEnv !== undefined && fromEnv !== '' - ? fromEnv - : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined - const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) - if (configPath === undefined || !existsSync(configPath)) { - process.stderr.write( - `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, - ) - process.exit(1) - } - - const ctx = await boot(NAME, configPath, undefined, undefined, bareModuleBaseUrl) - let exiting = false - - async function disposeAndExit(code: number): Promise { - if (exiting) return - exiting = true - try { - await ctx.fiber.dispose() - } finally { - process.exit(code) - } - } - - process.stdin.on('end', () => { void disposeAndExit(0) }) - process.on('SIGTERM', () => { void disposeAndExit(0) }) - process.on('SIGINT', () => { void disposeAndExit(130) }) -} -/* v8 ignore stop */ diff --git a/packages/sdk/python-runtime/tsconfig.json b/packages/sdk/python-runtime/tsconfig.json deleted file mode 100644 index ffd1ce9e41..0000000000 --- a/packages/sdk/python-runtime/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/loader" - }, - { - "path": "../../boot/app-boot" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/sdk/python-runtime/tsdown.config.ts b/packages/sdk/python-runtime/tsdown.config.ts deleted file mode 100644 index 9b6d5d33a7..0000000000 --- a/packages/sdk/python-runtime/tsdown.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** Builds each published entry as a self-contained file admitted by the package whitelist. */ -export default defineConfig([ - { - entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, - { - entry: ['lib/types/packaged-bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, -]) diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 4bd39ac878..dc3b63227f 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -1,12 +1,9 @@ /** Experimental-package publication and dependency constraints. */ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { checkExperimentalDependencyIsolation, checkExperimentalManifest, - checkWorkspaceManifest, type WorkspaceManifest, } from './check-workspace-constraints.ts' @@ -77,24 +74,3 @@ describe('experimental workspace constraints', () => { ]) }) }) - -describe('private Python runtime carrier', () => { - const manifest = JSON.parse( - readFileSync(new URL('../packages/sdk/python-runtime/package.json', import.meta.url), 'utf8'), - ) as WorkspaceManifest['manifest'] - - it('participates in dsh package checks without becoming an npm release member', () => { - expect(checkWorkspaceManifest({ dir: 'packages/sdk/python-runtime', manifest })).toEqual([]) - }) - - it('rejects publication metadata on the private carrier', () => { - const path = join('packages', 'sdk', 'python-runtime', 'package.json') - expect(checkWorkspaceManifest({ - dir: 'packages/sdk/python-runtime', - manifest: { ...manifest, private: false, publishConfig: { access: 'public' } }, - })).toEqual([ - `${path}: @deepseek-ai/dsh-sdk-python-runtime: private carrier must set "private": true`, - `${path}: @deepseek-ai/dsh-sdk-python-runtime: private carrier must omit publishConfig`, - ]) - }) -}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 842e0ca3a7..35e6eb1049 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -54,9 +54,6 @@ const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/ const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-' /** Directories whose packages this repository publishes: one release member each. */ const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ -/** Named dsh packages that remain private because another distribution embeds them. */ -const privateCarrierDirectories = new Set(['packages/sdk/python-runtime']) - const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { '@deepseek-ai/dsh': ['lib/*.js'], @@ -156,8 +153,6 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], // The shipped preset compositions travel inside the roster package. '@deepseek-ai/dsh-agent-presets': ['presets'], - // The private Python carrier ships only its closed-resolution entry. - '@deepseek-ai/dsh-sdk-python-runtime': ['lib/packaged-bin.js'], // The argv-prefix runner entry ships beside the lib as its own bundle; // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. @@ -292,13 +287,6 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st || manifest.repository.directory !== expectedDirectory) { errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`) } - } else if (privateCarrierDirectories.has(dir)) { - if (manifest.private !== true) { - errors.push(`${label}: private carrier must set "private": true`) - } - if (manifest.publishConfig !== undefined) { - errors.push(`${label}: private carrier must omit publishConfig`) - } } else if (releaseMemberDirectory.test(dir)) { // Release members state that they are publishable: npm refuses a private // package, and the repository field is how a consumer finds the source of diff --git a/scripts/verify-application-entrypoints.spec.ts b/scripts/verify-application-entrypoints.spec.ts index 02ba9a2196..ee590de033 100644 --- a/scripts/verify-application-entrypoints.spec.ts +++ b/scripts/verify-application-entrypoints.spec.ts @@ -74,12 +74,14 @@ describe('application entrypoints', () => { ]) }) - it('accepts the temporary private Python carrier source without an npm bin', () => { + it('rejects a private Python application carrier outside dsh', () => { const root = fixture() - write(root, 'packages/sdk/python-runtime/package.json', JSON.stringify({ private: true })) - write(root, 'packages/sdk/python-runtime/src/packaged-bin.ts', '#!/usr/bin/env node\n') + write(root, 'packages/sdk/rogue-python-runtime/package.json', JSON.stringify({ private: true })) + write(root, 'packages/sdk/rogue-python-runtime/src/bin.ts', '#!/usr/bin/env node\n') - expect(applicationEntrypointViolations(root)).toEqual([]) + expect(applicationEntrypointViolations(root)).toEqual([ + 'packages/sdk/rogue-python-runtime/src/bin.ts: executable source has no application/build/test classification', + ]) }) it('rejects a classified demo wrapper that launches a package entry', () => { diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index 7dec320e79..b273d6aed6 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -1,7 +1,7 @@ /** * Enforce dsh profiles as the only supported Node application launcher. - * Vendor CLIs, build tools, test tools, and the temporary private Python - * runtime carrier are explicit classifications rather than implicit holes. + * Vendor CLIs, build tools, and test tools are explicit classifications + * rather than implicit holes. */ import { existsSync, globSync, readFileSync } from 'node:fs' @@ -43,7 +43,6 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['packages/experimental/webworker-packer/bin.js', 'private build-only wrapper'], ['packages/experimental/webworker-packer/src/bin.ts', 'private build-only implementation'], ['packages/sdk/client/tests/fake-runtime.ts', 'test-only SDK runtime peer'], - ['packages/sdk/python-runtime/src/packaged-bin.ts', 'temporary private Python runtime carrier'], ['packages/test-support/llm-mock-server/src/bin.ts', 'test-only model server'], ]) @@ -194,6 +193,6 @@ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(p for (const failure of failures) console.error(` ${failure}`) process.exitCode = 1 } else { - console.log('verify-application-entrypoints: dsh is the only supported Node application launcher; the private Python carrier remains the temporary exception.') + console.log('verify-application-entrypoints: dsh is the only supported Node application launcher.') } } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1fb184af82..f83b621965 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -167,7 +167,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/jobs/jobs-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-jobs.' }, 'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, 'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' }, - 'packages/sdk/python-runtime': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/interaction/permission-presets': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/interaction/user-questions': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index b64992cdf1..91fbfd03f7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -267,7 +267,6 @@ { "path": "./packages/boot/app-boot" }, { "path": "./packages/boot/cmdline" }, { "path": "./packages/sdk/server" }, - { "path": "./packages/sdk/python-runtime" }, { "path": "./packages/test-support/llm-replay" }, { "path": "./packages/typert/generator" }, { "path": "./packages/test-support/acp-snapshot" }, From 809a4c5bade9c97d9a8b61cb4568f9bbfbf9ffa2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:55:42 +0800 Subject: [PATCH 21/42] fix(app-boot): preserve profile modules inside pkg executables Teach the profile installation fallback to use normal symlinks under Node and real ESM proxy packages under pkg. Each proxy records the source package version, mirrors its explicit runtime subpath exports, and re-exports the virtual /snapshot URLs, so built-in Loader rows and external plugin peers resolve one shared Cordis/module instance from an on-disk profile. Keep proxy healing idempotent, reject foreign real directories, cover root and subpath imports in packaged mode, and expand AggregateError startup diagnostics so concurrent Loader failures retain their individual import causes. This is the reusable packaged-profile mechanism; Python-specific artifact wiring remains in the next commit. --- packages/boot/app-boot/src/index.ts | 6 +- packages/boot/app-boot/src/profile.ts | 146 ++++++++++++++++-- packages/boot/app-boot/tests/app-boot.spec.ts | 22 +++ packages/boot/app-boot/tests/profile.spec.ts | 99 +++++++++++- 4 files changed, 260 insertions(+), 13 deletions(-) diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index ecfe695f34..9a1d05ffbf 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -1,5 +1,5 @@ /** - * Shared boot glue for `dsh` profiles and the temporarily packaged Python SDK runtime: load the gitignored + * Shared boot glue for `dsh` profiles, including the CLI packaged by the Python runtime wheel: load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. @@ -811,7 +811,9 @@ export async function boot( // original activation error instead of only the wrap chain. let deepest: unknown = cause while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause - const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + const stack = deepest instanceof AggregateError + ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` + : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } } diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index cbd6074ff9..e886f759b2 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -24,9 +24,10 @@ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' @@ -238,22 +239,140 @@ function ensureSymlink(link: string, target: string): void { } } +interface ModuleProxyManifest { + name: string + version: string + private: true + type: 'module' + exports: Record + dsh: { moduleFallback: { targets: Record } } +} + +interface ModuleProxyRecord { + version?: unknown + dsh?: { moduleFallback?: { targets?: unknown } } +} + +/** Return whether the process reads application modules from pkg's virtual filesystem. */ +function isPackagedExecutable(): boolean { + return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined +} + +/** Resolve one package specifier from the dsh installation. */ +function packageEntryFromAnchor(anchor: string, specifier: string): string | undefined { + try { + return createRequire(anchor).resolve(specifier) + } catch { + return undefined + } +} + +/** Resolve every explicit runtime export that an out-of-tree plugin can import. */ +function packageProxySource( + installAnchor: string, + packageName: string, + packageDir: string, +): { version: string; targets: Record } { + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + exports?: unknown + version?: unknown + } + if (typeof manifest.version !== 'string' || manifest.version.length === 0) { + throw new Error(`dsh: installed package ${packageName} must declare a non-empty version`) + } + const declared = manifest.exports + const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared) + && Object.keys(declared).some(key => key.startsWith('.')) + ? Object.keys(declared).filter(key => key === '.' || (key.startsWith('./') && !key.includes('*') && key !== './package.json')) + : ['.'] + const targets: Record = {} + for (const subpath of subpaths) { + const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) + const entry = packageEntryFromAnchor(installAnchor, specifier) + if (entry !== undefined) targets[subpath] = pathToFileURL(entry).href + } + return { version: manifest.version, targets } +} + +/** + * Materialize a real package proxy whose exports retain pkg's virtual module + * URL. Files outside the executable cannot traverse a symlink into + * `/snapshot`, while an ESM re-export can import that URL and preserves the + * executable's single module instance for out-of-tree plugin peers. + */ +function ensureModuleProxy( + link: string, + packageName: string, + version: string, + targets: Record, +): void { + const proxyExports = Object.fromEntries( + Object.keys(targets).map((subpath, index) => [subpath, `./entry-${index}.js`]), + ) + const manifest: ModuleProxyManifest = { + name: packageName, + version, + private: true, + type: 'module', + exports: proxyExports, + dsh: { moduleFallback: { targets } }, + } + let stat + try { + stat = lstatSync(link) + } catch { + stat = undefined + } + if (stat?.isSymbolicLink()) { + unlinkSync(link) + stat = undefined + } + if (stat !== undefined) { + const marker = join(link, 'package.json') + let existing: ModuleProxyRecord | undefined + try { + existing = JSON.parse(readFileSync(marker, 'utf8')) as ModuleProxyRecord + } catch { + existing = undefined + } + if (existing?.dsh?.moduleFallback?.targets === undefined) { + throw new Error(`dsh: ${link} exists and is not a dsh-managed module proxy; remove it so dsh can manage the installation fallback`) + } + if (existing.version === version + && JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets)) return + rmSync(link, { recursive: true }) + } + mkdirSync(link, { recursive: true }) + writeFileSync(join(link, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') + for (const [index, target] of Object.values(targets).entries()) { + const specifier = JSON.stringify(target) + writeFileSync( + join(link, `entry-${index}.js`), + `export * from ${specifier}\nimport * as target from ${specifier}\nexport default target.default\n`, + ) + } +} + /** * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one - * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS + * entry per package in the dsh app's resolvable dependency CLOSURE (BFS * over `dependencies` from the app manifest), each resolved from its own - * real location. Node's parent-directory walk from any profile finds this + * installation location. Plain Node uses symlinks. A pkg executable writes + * small ESM proxy packages instead because the host filesystem cannot follow + * a symlink into pkg's virtual `/snapshot` tree; the proxy re-exports the + * virtual URL, preserving the executable's single module instance. Node's + * parent-directory walk from any profile finds this * directory after the profile's own `node_modules`, so every in-box plugin * resolves without pnpm ever managing it — the exact "bundles come from the * installation" contract. The closure (not just direct dependencies) is * required for out-of-tree plugins: their peer dependencies name Service * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app - * reaches only through its Service Provider packages. Symlinked packages - * resolve their own dependencies from their real directories (Node's default - * symlink-following), so each package needs only its one flat link. - * Idempotent: correct links are kept and moved installations are - * re-pointed; a stale link to a vanished package stays until its name is - * reused (dangling links are invisible to resolution). + * reaches only through its Service Provider packages. Both a symlink target + * and a proxy's virtual target resolve transitive imports from the original + * package directory, so each package needs one flat fallback entry. + * Idempotent: correct entries are kept and changed installation targets are + * rewritten; under plain Node, a stale dangling link stays until its name is + * reused because resolution cannot discover it. * @param installAnchor - absolute path of the dsh app's package.json. * @param home - the Harness home; defaults to {@link resolveDshHome}. */ @@ -287,7 +406,14 @@ export function healProfilesModuleFallback(installAnchor: string, home: string = for (const [packageName, target] of links) { const link = join(modulesDir, packageName) mkdirSync(dirname(link), { recursive: true }) - ensureSymlink(link, target) + if (isPackagedExecutable()) { + const source = packageProxySource(installAnchor, packageName, target) + if (Object.keys(source.targets).length > 0) { + ensureModuleProxy(link, packageName, source.version, source.targets) + } + } else { + ensureSymlink(link, target) + } } } diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 8726895c1b..8ae1a21cd2 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -771,6 +771,28 @@ describe('boot', () => { ) }) + it('expands a stackless aggregate at the deepest activation cause', async () => { + const dir = tmp() + const aggregate = new AggregateError([ + new Error('first aggregate member'), + 'second aggregate member', + ], 'aggregate activation failure') + delete (aggregate as { stack?: string }).stack + try { + await boot(NAME, join(dir, 'cordis.yml'), undefined, () => { + throw new Error('wrapped aggregate failure', { cause: aggregate }) + }) + expect.fail('boot should reject the aggregate activation failure') + } catch (error) { + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain(`${NAME}: host preparation failed: wrapped aggregate failure`) + expect(message).toContain('aggregate activation failure') + expect(message).toContain('first aggregate member') + expect(message).toContain('second aggregate member') + } + }) + it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { const dir = tmp() writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 92265d2fdb..3710859380 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -36,12 +36,18 @@ function stageInstallation(bundles: Record { const fallback = join(home, 'profiles', 'node_modules') expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) }) + + it('writes real ESM proxies for a packaged executable', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + bundleManifest.exports = { '.': './index.js', './feature': './feature.js' } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) + writeFileSync(join(bundleDir, 'feature.js'), 'export const feature = "proxied"\n') + const home = tmp() + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + const proxy = join(fallback, 'bundle-a') + expect(lstatSync(proxy).isDirectory()).toBe(true) + const proxyManifest = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { + version: unknown + exports: unknown + dsh: { moduleFallback: { targets: Record } } + } + expect(proxyManifest).toMatchObject({ + version: '0.0.0', + exports: { '.': './entry-0.js', './feature': './entry-1.js' }, + }) + expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js')) + await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) + await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' }) + healProfilesModuleFallback(anchor, home) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('requires a package version before writing a packaged proxy', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.version = '' + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + expect(() => { healProfilesModuleFallback(anchor, tmp()) }).toThrow( + 'installed package bundle-a must declare a non-empty version', + ) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('replaces plain-node links and stale managed proxies in packaged mode', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + healProfilesModuleFallback(anchor, home) + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + expect(lstatSync(proxy).isSymbolicLink()).toBe(true) + + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + healProfilesModuleFallback(anchor, home) + expect(lstatSync(proxy).isDirectory()).toBe(true) + const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { + version: string + } + stale.version = 'stale' + writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale)) + healProfilesModuleFallback(anchor, home) + expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({ + version: '0.0.0', + }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('rejects foreign packaged fallback directories with valid or invalid metadata', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + for (const metadata of ['{}', '{']) { + const home = tmp() + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + mkdirSync(proxy, { recursive: true }) + writeFileSync(join(proxy, 'package.json'), metadata) + expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow( + 'exists and is not a dsh-managed module proxy', + ) + } + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) }) From be7b0645041ef203f3b6516e4623ab63570390cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:02 +0800 Subject: [PATCH 22/42] feat(python-runtime): package the dsh CLI and profile assets Make the zero-code dsh-python-runtime-closure depend on the real @deepseek-ai/dsh application and every required profile peer, then package apps/cli's built bin instead of the deleted Python carrier. Rename executables to deepseek-harness-sdk-runtime--, update wheel/platform/build workflow discovery, and install a Python dsh console command that requires explicit DSH_HOME before exec. Include profile, bundle, preset, native addon, and shared-library assets needed by the full CLI. Remove the checked-in default cordis.yml and preserve the existing wheel distribution names, Python module names, sidecar validation, and wire identity. Runtime resolution and release tests pin the new artifacts and dev Node carrier. --- .../workflows/build-exe-for-python-sdk.yml | 2 +- .gitignore | 2 +- .gitlab-ci.yml | 2 +- pnpm-lock.yaml | 22 ++----- python/sdk-runtime/hatch_build.py | 4 +- python/sdk-runtime/package.json | 7 ++- python/sdk-runtime/platforms.json | 6 +- python/sdk-runtime/pyproject.toml | 10 +-- .../src/deepseek_harness_runtime/__init__.py | 51 +++++++-------- .../runtime/cordis.yml | 58 ----------------- python/sdk/tests/test_release_version.py | 9 ++- python/sdk/tests/test_runtime_resolution.py | 63 +++++++++++++++---- scripts/build-exe-for-python-sdk.ts | 22 ++++--- scripts/build-python-release.py | 4 +- scripts/translation-pairing.spec.ts | 2 +- scripts/translation-pairing.ts | 4 +- 16 files changed, 125 insertions(+), 143 deletions(-) delete mode 100644 python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index b9ba5e148e..04501d5deb 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -235,7 +235,7 @@ jobs: run: | set -euo pipefail platform="${TARGET#node24-}" - exe="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$platform" + exe="$PWD/dist-exe/deepseek-harness-sdk-runtime-$platform" [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } case "$platform" in linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; diff --git a/.gitignore b/.gitignore index 70c355e391..d33bbc8a4e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ tmp/ .idea mise.toml dist-exe/ -python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-* +python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-* python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ python/**/__pycache__/ python/**/.pytest_cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index cf5a093f69..faa9402c42 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -38,7 +38,7 @@ sdk-wheel: - pnpm install --frozen-lockfile - pnpm run verify-runtime-closure - pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="$PKG_TARGET" - - EXE="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$PLATFORM" + - EXE="$PWD/dist-exe/deepseek-harness-sdk-runtime-$PLATFORM" - test -x "$EXE" - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aef169a41..9deab88d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6826,19 +6826,6 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/sdk/python-runtime: - dependencies: - '@deepseek-ai/dsh-app-boot': - specifier: workspace:^ - version: link:../../boot/app-boot - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - packages/sdk/server: dependencies: '@deepseek-ai/schemastery': @@ -9618,6 +9605,9 @@ importers: '@deepseek-ai/cosmokit': specifier: link:../../vendor/cosmokit version: link:../../vendor/cosmokit + '@deepseek-ai/dsh': + specifier: workspace:^ + version: link:../../apps/cli '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp @@ -9786,9 +9776,6 @@ importers: '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ version: link:../../packages/sdk/protocol - '@deepseek-ai/dsh-sdk-python-runtime': - specifier: workspace:^ - version: link:../../packages/sdk/python-runtime '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -9819,6 +9806,9 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../../packages/session/session-telemetry '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session/session-title diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index ef5a621f9d..c4083387a6 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -66,7 +66,9 @@ class RuntimeBuildHook(BuildHookInterface): ) expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" - runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + runtime_files = sorted( + runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] + ) expected_files = [expected_executable, f"{expected_executable}-rg"] if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index dad5db82b7..d5684867e6 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -1,6 +1,6 @@ { - "name": "dsh-sdk-python-runtime-closure", - "description": "Dependency-only deploy root defining the executable and Python runtime closure; pnpm deploy materializes this manifest and node_modules.", + "name": "dsh-python-runtime-closure", + "description": "Dependency-only deploy root defining the dsh executable shipped by the Python runtime wheel.", "version": "0.0.1", "private": true, "type": "module", @@ -10,6 +10,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/cosmokit": "workspace:^", + "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -45,7 +46,6 @@ "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", - "@deepseek-ai/dsh-sdk-python-runtime": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json index 069378e8cb..e65cd6a735 100644 --- a/python/sdk-runtime/platforms.json +++ b/python/sdk-runtime/platforms.json @@ -1,14 +1,14 @@ { "linux-x64": { "tag": "manylinux_2_28_x86_64", - "executable": "dsh-jsonrpc-agent-pkg-linux-x64" + "executable": "deepseek-harness-sdk-runtime-linux-x64" }, "linux-arm64": { "tag": "manylinux_2_28_aarch64", - "executable": "dsh-jsonrpc-agent-pkg-linux-arm64" + "executable": "deepseek-harness-sdk-runtime-linux-arm64" }, "macos-arm64": { "tag": "macosx_14_0_arm64", - "executable": "dsh-jsonrpc-agent-pkg-macos-arm64" + "executable": "deepseek-harness-sdk-runtime-macos-arm64" } } diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index 090efc6139..4005314aa0 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "deepseek-harness-runtime-bin" version = "0.0.0.dev0" -description = "Pinned DeepSeek Harness runtime for the Python SDK" +description = "Bundled dsh CLI runtime for the DeepSeek Harness Python SDK" readme = "README.md" requires-python = ">=3.10" license = "MIT" @@ -17,10 +17,12 @@ Documentation = "https://github.com/deepseek-ai/deepseek-harness/blob/master/pyt Issues = "https://github.com/deepseek-ai/deepseek-harness/issues" Source = "https://github.com/deepseek-ai/deepseek-harness" -# Include the injected executable and default config; exclude the dev-only node -# closure from wheels and sdists. +[project.scripts] +dsh = "deepseek_harness_runtime:main" + +# Include the injected dsh executable and sidecars; exclude the dev-only node closure. [tool.hatch.build] -artifacts = ["src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*"] +artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*"] exclude = ["src/deepseek_harness_runtime/runtime/node"] [tool.hatch.build.targets.wheel] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 94aca48d9f..0fc4f416c0 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -1,22 +1,21 @@ -"""Locate the bundled DeepSeek Harness SDK runtime shipped with this package. +"""Locate and execute the bundled dsh CLI shipped with the Python SDK runtime. Two runtime carriers coexist under ``runtime/``, both injected by the repo's ``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): - **exe (production)**: single-file Node executables named - ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in + ``deepseek-harness-sdk-runtime--`` (platform in {linux, macos}, arch in {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling ``-spawn-helper``. The target machine needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node - runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`` on a + runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a system Node >= 22.19. It is the current checkout's source build, never selected automatically, and excluded from wheel/sdist distributions. -``runtime/cordis.yml`` IS checked in: it is the default agent configuration -the client SDK injects via ``$DSH_CORDIS_CONFIG`` for zero-config runs — the -runtime itself always requires an explicit config and has no built-in -fallback. +Both carriers execute the same dsh command grammar. The Python SDK selects the +``sdk`` profile and requires an explicit Harness home; the installed ``dsh`` +console command requires ``DSH_HOME`` for the same reason. """ from __future__ import annotations @@ -52,21 +51,6 @@ def bundled_package_dir() -> Path: return root -def bundled_default_config_path() -> Path: - """Path of the checked-in default runtime configuration (``runtime/cordis.yml``). - - The client SDK injects this path via ``$DSH_CORDIS_CONFIG`` when the caller - supplies no config and the launch resolves to the bundled runtime — the - runtime binary itself always demands an explicit config. - """ - path = bundled_package_dir() / "runtime" / "cordis.yml" - if not path.is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the default runtime config at {path}" - ) - return path - - def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. @@ -78,7 +62,7 @@ def bundled_runtime_path() -> Path: touching callers). """ tag = _current_platform_tag() - path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" + path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}" if not path.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " @@ -128,7 +112,7 @@ def _current_platform_tag() -> str: arch = _ARCH_TAGS.get(platform.machine().lower()) if plat is None or arch is None: raise FileNotFoundError( - "no bundled dsh-jsonrpc-agent executable exists for this platform " + "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT ) @@ -141,9 +125,9 @@ def _node_launch_args() -> tuple[str, str]: node_root / "node_modules" / "@deepseek-ai" - / "dsh-sdk-python-runtime" + / "dsh" / "lib" - / "packaged-bin.js" + / "bin.js" ) if not bin_js.is_file(): raise FileNotFoundError( @@ -161,11 +145,24 @@ def _node_launch_args() -> tuple[str, str]: return (node, str(bin_js)) +def main() -> None: + """Execute the bundled dsh CLI with an explicitly selected Harness home.""" + if not os.environ.get("DSH_HOME", "").strip(): + print( + "dsh: the Python runtime command requires an explicit DSH_HOME; " + "it never uses ~/.dsh implicitly", + file=sys.stderr, + ) + raise SystemExit(2) + argv = (*resolve_bundled_launch_args(), *sys.argv[1:]) + os.execvpe(argv[0], argv, os.environ) + + __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", - "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", + "main", "resolve_bundled_launch_args", ] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml deleted file mode 100644 index 02f8cac145..0000000000 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Bundled default config. The runtime still requires an explicit -# $DSH_CORDIS_CONFIG or argv path; the SDK injects this path for bundled -# zero-config launches. SDK-set session-root and cwd variables have manual-run fallbacks. - -# Stdio JSON-RPC server entry; without it the agent has no SDK client. -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' - -# Agent spine; the SDK server creates agents per sessionId. -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: - maxBytes: 65536 - -# Stock DeepSeek adapters. The adapter resolves DEEPSEEK_API_KEY through the -# credential seam and, with no provider mounted here, from the launching -# environment; DEEPSEEK_BASE_URL follows the same environment ladder. Neither -# is inlined, so this file names no secret and no route. -- id: deepseek-llm-api-extensions - name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' - -- id: session-log-deepseek - name: '@deepseek-ai/dsh-session-log-deepseek' - -- id: plugin-package-inventory-deepseek - name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - -# JSONL persistence; $DSH_SESSION_ROOT wins over ./.sessions in the process cwd. -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - -# Persistence owns durable storage; this separate policy explicitly selects -# the request, tool-dispatch, and completed-step durability checkpoints. -- id: session-checkpoints - name: '@deepseek-ai/dsh-session-checkpoint-policy' - -# Local bash executor; $DSH_CWD wins over the process cwd. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -# Local filesystem provider for workspace instruction loading. This does not -# expose model-facing file tools by itself. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b21a485949..deaa65b8a8 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -59,6 +59,7 @@ def test_pep440_version_spells_a_prerelease_the_python_way() -> None: def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: assert build_python_release.PLATFORMS["macos-arm64"][0] == "macosx_14_0_arm64" + assert build_python_release.PLATFORMS["macos-arm64"][1] == "deepseek-harness-sdk-runtime-macos-arm64" def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None: @@ -88,7 +89,7 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool ) -> None: - executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" + executable = tmp_path / f"deepseek-harness-sdk-runtime-{target}" executable.write_bytes(b"runtime") executable.chmod(0o755) expected = {executable.name: b"runtime"} @@ -106,10 +107,14 @@ def test_stage_runtime_copies_platform_payload( build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected + assert { + path.name: path.read_bytes() + for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*") + } == expected pyproject = (destination / "pyproject.toml").read_text() assert 'license = "MIT"' in pyproject assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject + assert 'dsh = "deepseek_harness_runtime:main"' in pyproject assert (destination / "platforms.json").read_bytes() == ( ROOT / "python" / "sdk-runtime" / "platforms.json" ).read_bytes() diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 14e90f3283..fc54171b75 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -9,21 +9,12 @@ import pytest from deepseek_harness_runtime import ( RUNTIME_MODE_ENV_VAR, - bundled_default_config_path, bundled_package_dir, + main, resolve_bundled_launch_args, ) -def test_default_config_is_shipped_with_the_package() -> None: - path = bundled_default_config_path() - assert path == bundled_package_dir() / "runtime" / "cordis.yml" - config = path.read_text() - assert "@deepseek-ai/dsh-agent-spine-demo" in config - assert "@deepseek-ai/dsh-session-persistence-jsonl" in config - assert "@deepseek-ai/dsh-session-checkpoint-policy" in config - - def test_unknown_explicit_mode_fails_loud() -> None: with pytest.raises(ValueError, match="expected 'exe' or 'node'"): resolve_bundled_launch_args("bogus") @@ -49,10 +40,10 @@ def test_runtime_requires_spawn_helper_only_on_macos( ) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() - linux = runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64" + linux = runtime_dir / "deepseek-harness-sdk-runtime-linux-x64" linux.touch() Path(f"{linux}-rg").touch() - macos = runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64" + macos = runtime_dir / "deepseek-harness-sdk-runtime-macos-arm64" macos.touch() Path(f"{macos}-rg").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) @@ -69,9 +60,55 @@ def test_runtime_requires_ripgrep_sidecar( ) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() - (runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64").touch() + (runtime_dir / "deepseek-harness-sdk-runtime-linux-x64").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") with pytest.raises(FileNotFoundError, match="ripgrep sidecar"): runtime.bundled_runtime_path() + + +def test_node_mode_runs_the_deployed_dsh_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + bin_js = tmp_path / "runtime" / "node" / "node_modules" / "@deepseek-ai" / "dsh" / "lib" / "bin.js" + bin_js.parent.mkdir(parents=True) + bin_js.touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime.shutil, "which", lambda _name: "/node") + + assert resolve_bundled_launch_args("node") == ("/node", str(bin_js)) + + +def test_python_dsh_command_requires_explicit_home( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.delenv("DSH_HOME", raising=False) + + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 2 + assert "explicit DSH_HOME" in capsys.readouterr().err + + +def test_python_dsh_command_executes_the_bundled_cli( + monkeypatch: pytest.MonkeyPatch +) -> None: + called: dict[str, object] = {} + monkeypatch.setenv("DSH_HOME", "/explicit/home") + monkeypatch.setattr(runtime, "resolve_bundled_launch_args", lambda: ("/runtime",)) + monkeypatch.setattr(runtime.sys, "argv", ["dsh", "plugin", "--profile", "sdk", "list"]) + + def execvpe(file: str, args: tuple[str, ...], env: dict[str, str]) -> None: + called.update(file=file, args=args, home=env.get("DSH_HOME")) + + monkeypatch.setattr(runtime.os, "execvpe", execvpe) + + main() + + assert called == { + "file": "/runtime", + "args": ("/runtime", "plugin", "--profile", "sdk", "list"), + "home": "/explicit/home", + } diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 7516fcabb6..f7d696edfe 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -1,5 +1,5 @@ /** - * Build the SDK runtime executables and Python node carrier. The fixed + * Build the dsh executables and development Node carrier for the Python runtime wheel. The fixed * `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by * .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. * The staged closure is symlink-free, and whole-tree assets cover Cordis's @@ -16,11 +16,11 @@ import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty. const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ -const DEPLOY_ROOT_PACKAGE = 'dsh-sdk-python-runtime-closure' -/** The closed-runtime app entry inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js' -/** Stable Python-visible executable basename; rename with the later Python runtime migration. */ -const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +const DEPLOY_ROOT_PACKAGE = 'dsh-python-runtime-closure' +/** The sole application launcher inside the deployed closure. */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh/lib/bin.js' +/** Python-visible executable basename. */ +const OUTPUT_BASENAME = 'deepseek-harness-sdk-runtime' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -47,8 +47,15 @@ const ASSET_GLOBS = [ 'node_modules/**/*.mjs', 'node_modules/**/package.json', 'node_modules/**/*.json', + 'node_modules/**/*.md', + 'node_modules/**/*.dylib', + 'node_modules/**/*.dll', 'node_modules/**/*.node', + 'node_modules/**/*.so', + 'node_modules/**/*.so.*', 'node_modules/**/*.wasm', + 'node_modules/**/*.yaml', + 'node_modules/**/*.yml', ] const PLATFORMS = ['linux', 'macos'] as const @@ -220,8 +227,7 @@ function formatCommand(command: string, args: string[]): string { */ class SingleExeBuild { /** - * The cleared deploy target, pkg input, and Python node-mode carrier. The - * checked-in default `cordis.yml` remains in its parent directory. + * The cleared deploy target, pkg input, and Python node-mode carrier. */ readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR) private readonly outDir = resolve(root, OUT_DIR) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 0b4157f40d..c546c6bb42 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -150,7 +150,7 @@ def copy_package(source: Path, destination: Path) -> None: "*.pyc", "dist", "node_modules", - "dsh-jsonrpc-agent-pkg-*", + "deepseek-harness-sdk-runtime-*", ), ) @@ -246,7 +246,7 @@ def verify_wheel( f"{wheel} has license files {license_files}, expected {expected_license_files}" ) runtime_files = [ - name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name + name for name in archive.namelist() if "/runtime/deepseek-harness-sdk-runtime-" in name ] if package == "runtime": assert platform is not None diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index c4dda468fe..e36b19815d 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -310,7 +310,7 @@ describe('translation scope discovery', () => { 'packages/example/node_modules/dependency/README.md', 'packages/example/lib/README.md', 'coverage/report/README.md', - 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-macos-arm64/README.md', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-macos-arm64/README.md', 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md', ])('excludes non-source or non-README path %s', (file) => { expect(isTranslationScopeFile(file)).toBe(false) diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 95b70c48b1..e4c0919a2f 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -165,7 +165,7 @@ export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [ '**/.pytest_cache/**', 'apps/web/dist/**', '.artifacts/**', - 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*/**', + 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*/**', 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**', 'vendor/**', ] @@ -177,7 +177,7 @@ function isTranslationSourceExcluded(file: string): boolean { || segment.startsWith('.doc-typecheck-') || segment.startsWith('.node-next-types-')) || file.startsWith('apps/web/dist/') - || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-') + || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-') || file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/') } From 56e038b2e33dac3a57bdf14f42992860340ac1b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:16 +0800 Subject: [PATCH 23/42] feat(python-sdk): launch dsh profiles from explicit homes Replace complete-config, session_root, runtime-bin, bridge-bin, and public argv override options with dsh_bin, profile, ordered patches, and dsh_home. Resolve executable/home/patch/cwd paths before spawn, select the sdk profile by default, and fail before launch unless dsh_home or non-empty DSH_HOME is explicit; Python never inherits ~/.dsh silently. Remove Python-owned DSH_CORDIS_CONFIG, DSH_SESSION_ROOT, and DSH_CWD injection and drop session_root from RunResult. Keep arbitrary argv only as an underscore-prefixed fake-runtime adapter, retain provider/model/token and process controls, and append subprocess stderr to initialization JSON-RPC errors so profile boot failures name their actual plugin cause. Unit and carrier tests cover both exe and Node modes. --- python/sdk/src/deepseek_harness/api.py | 32 ++-- python/sdk/src/deepseek_harness/client.py | 82 +++++----- python/sdk/tests/manual_sdk_agent_smoke.py | 30 ++-- python/sdk/tests/test_bundled_runtime.py | 130 ++++++---------- python/sdk/tests/test_client.py | 166 ++++++++++++--------- 5 files changed, 225 insertions(+), 215 deletions(-) diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 9c542c012a..1b33524118 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -24,11 +24,11 @@ class DeepSeekHarnessConfig: max_tokens: int | None = None cwd: str | None = None runtime_cwd: str | None = None - session_root: str | None = None - cordis: str | None = None + dsh_bin: str | None = None + profile: str = "sdk" + patches: tuple[str, ...] = () + dsh_home: str | None = None env: dict[str, str] = field(default_factory=dict) - runtime_bin: str | None = None - launch_args_override: tuple[str, ...] | None = None request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None @@ -42,7 +42,6 @@ class RunResult: finish_reason: str | None events: list[JsonObject] notifications: list[Notification] - session_root: str | None = None class DeepSeekHarness: @@ -53,7 +52,13 @@ class DeepSeekHarness: :meth:`close` explicitly when finished, so the subprocess is always reaped. """ - def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None: + def __init__( + self, + config: DeepSeekHarnessConfig | None = None, + *, + _launch_args: tuple[str, ...] | None = None, + **kwargs: object, + ) -> None: if config is not None and kwargs: raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both") self.config = config or DeepSeekHarnessConfig(**kwargs) @@ -61,11 +66,6 @@ class DeepSeekHarness: runtime_cwd = str(Path(self.config.runtime_cwd).resolve()) if self.config.runtime_cwd is not None else cwd self._cwd = cwd env = dict(self.config.env) - if self.config.session_root is not None: - env["DSH_SESSION_ROOT"] = self.config.session_root - if self.config.cordis is not None: - env["DSH_CORDIS_CONFIG"] = self.config.cordis - env["DSH_CWD"] = cwd if self.config.base_url is not None: env["DEEPSEEK_BASE_URL"] = self.config.base_url if self.config.api_key is not None: @@ -73,13 +73,16 @@ class DeepSeekHarness: self._client = HarnessClient( HarnessConfig( - runtime_bin=self.config.runtime_bin, - launch_args_override=self.config.launch_args_override, + dsh_bin=self.config.dsh_bin, + profile=self.config.profile, + patches=self.config.patches, + dsh_home=self.config.dsh_home, cwd=runtime_cwd, env=env, request_timeout_seconds=self.config.request_timeout_seconds, shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, - ) + ), + _launch_args=_launch_args, ) self._initialized = False @@ -179,7 +182,6 @@ class Session: finish_reason=finish_reason(events), events=events, notifications=notifications, - session_root=self.harness.config.session_root, ) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 5442c7e144..8969bc7901 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -25,20 +25,28 @@ NotificationFilter: TypeAlias = Callable[[Notification], bool] class HarnessConfig: """Configuration for launching the local DeepSeek Harness SDK runtime.""" - runtime_bin: str | None = None - bridge_bin: str | None = None - launch_args_override: tuple[str, ...] | None = None + dsh_bin: str | None = None + profile: str = "sdk" + patches: tuple[str, ...] = () + dsh_home: str | None = None cwd: str | None = None env: dict[str, str] | None = None request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 + _launch_args: tuple[str, ...] | None = None class HarnessClient: """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio.""" - def __init__(self, config: HarnessConfig | None = None) -> None: + def __init__( + self, + config: HarnessConfig | None = None, + *, + _launch_args: tuple[str, ...] | None = None, + ) -> None: self.config = config or HarnessConfig() + self._launch_args = _launch_args or self.config._launch_args self._proc: subprocess.Popen[str] | None = None self._lock = threading.Lock() self._write_lock = threading.Lock() @@ -65,11 +73,10 @@ class HarnessClient: return with self._lock: self._session_parents.clear() - args = list(self.config.launch_args_override or self._default_launch_args()) env = os.environ.copy() if self.config.env: env.update(self.config.env) - self._inject_bundled_default_config(env) + args = list(self._launch_args or self._default_launch_args(env)) self._proc = subprocess.Popen( args, stdin=subprocess.PIPE, @@ -131,8 +138,15 @@ class HarnessClient: payload["maxTokens"] = max_tokens try: return self.request("initialize", payload, response_model=InitializeResponse) - except BaseException: + except BaseException as error: self.close() + diagnostics = self._runtime_diagnostics() + if isinstance(error, JsonRpcError) and diagnostics: + raise JsonRpcError( + error.code, + f"{error.message}\n{diagnostics}", + error.data, + ) from error raise def session_prompt( @@ -421,37 +435,35 @@ class HarnessClient: parts.append("stderr tail:\n" + "\n".join(self._stderr_lines)) return "\n".join(parts) - def _default_launch_args(self) -> tuple[str, ...]: - if self.config.runtime_bin is not None: - return (self.config.runtime_bin,) - if self.config.bridge_bin is not None: - return (self.config.bridge_bin,) - try: - from deepseek_harness_runtime import resolve_bundled_launch_args - except ImportError as exc: - raise FileNotFoundError( - "Unable to locate the bundled DeepSeek Harness SDK runtime. " - "Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin." - ) from exc - return resolve_bundled_launch_args() + def _default_launch_args(self, env: dict[str, str]) -> tuple[str, ...]: + if self.config.dsh_bin is None: + try: + from deepseek_harness_runtime import resolve_bundled_launch_args + except ImportError as exc: + raise FileNotFoundError( + "Unable to locate the bundled DeepSeek Harness dsh runtime. " + "Install deepseek-harness-runtime-bin." + ) from exc + base = resolve_bundled_launch_args() + else: + base = (str(Path(self.config.dsh_bin).expanduser().resolve()),) - def _inject_bundled_default_config(self, env: dict[str, str]) -> None: - """Inject the default config for a bundled launch with no non-empty config. + if self.config.dsh_home is not None: + if not self.config.dsh_home.strip(): + raise ValueError("HarnessConfig requires a non-empty dsh_home") + env["DSH_HOME"] = str(Path(self.config.dsh_home).expanduser().resolve()) + elif not env.get("DSH_HOME", "").strip(): + raise ValueError( + "HarnessConfig requires an explicit dsh_home or non-empty DSH_HOME; " + "the Python SDK never uses ~/.dsh implicitly" + ) - Both bundled carriers require an explicit config. Explicit runtime, - launch-argument, and config channels remain untouched. - """ - uses_bundled_runtime = ( - self.config.launch_args_override is None - and self.config.runtime_bin is None - and self.config.bridge_bin is None + patches = tuple( + argument + for patch in self.config.patches + for argument in ("--patch", str(Path(patch).expanduser().resolve())) ) - if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"): - return - # _default_launch_args already imported the package or raised its install error. - from deepseek_harness_runtime import bundled_default_config_path - - env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) + return (*base, "--profile", self.config.profile, *patches) def _unsubscribe_notifications(self, subscription_id: str) -> None: with self._lock: diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index c0305d9211..acadb495be 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -1,4 +1,4 @@ -"""Drive the repo-source JSON-RPC bin through the SDK and a keyless mock SSE server. +"""Drive the repo-source dsh SDK profile through the SDK and a keyless mock SSE server. Requires ``pnpm install`` but no build. This manual test is not collected by pytest; run ``python tests/manual_sdk_agent_smoke.py``. @@ -16,7 +16,6 @@ from pathlib import Path from typing import Any from deepseek_harness import DeepSeekHarness -from deepseek_harness_runtime import bundled_default_config_path class MockCompletionHandler(BaseHTTPRequestHandler): @@ -43,15 +42,16 @@ class MockCompletionHandler(BaseHTTPRequestHandler): def run_smoke(repo_root: Path, keep_sessions: bool) -> None: - session_root = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-sessions-")) - runtime_entry = repo_root / "packages/sdk/python-runtime/src/packaged-bin.ts" + dsh_home = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-home-")) + session_root = dsh_home / "sessions" + runtime_entry = repo_root / "apps/cli/src/bin.ts" server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) thread.start() base_url = f"http://127.0.0.1:{server.server_address[1]}" print(f"repo_root={repo_root}") - print(f"session_root={session_root}") + print(f"dsh_home={dsh_home}") print(f"mock_base_url={base_url}") try: @@ -59,10 +59,18 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: model="sdk-smoke-model", cwd=str(repo_root / "python/sdk"), runtime_cwd=str(repo_root), - session_root=str(session_root), - cordis=str(bundled_default_config_path()), - launch_args_override=("node", "--import", "tsx", str(runtime_entry)), + _launch_args=( + "node", + "--import", + "tsx", + str(runtime_entry), + "--profile", + "sdk", + ), env={ + "DSH_HOME": str(dsh_home), + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", "DEEPSEEK_BASE_URL": base_url, "DEEPSEEK_API_KEY": "sdk-smoke-key", }, @@ -92,10 +100,10 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: server.server_close() if keep_sessions: - print(f"kept_session_root={session_root}") + print(f"kept_dsh_home={dsh_home}") else: - shutil.rmtree(session_root) - print("removed temporary session root") + shutil.rmtree(dsh_home) + print("removed temporary dsh home") def main() -> None: diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 52d84cd161..3da5247795 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -1,4 +1,4 @@ -"""Keyless boot tests for the production exe and development node carrier. +"""Keyless boot tests for the production exe and development dsh carrier. Each carrier skips independently when absent. The dummy API key only satisfies adapter loading; initialize and shutdown do not call a model. @@ -6,64 +6,39 @@ adapter loading; initialize and shutdown do not call a model. from __future__ import annotations +import json from pathlib import Path import pytest from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig -from deepseek_harness.errors import TransportClosedError -from deepseek_harness_runtime import resolve_bundled_launch_args +from deepseek_harness.errors import JsonRpcError, TransportClosedError +from deepseek_harness_runtime import RUNTIME_MODE_ENV_VAR, resolve_bundled_launch_args _MODES = ("exe", "node") -_REPO_ROOT = Path(__file__).parents[3] -_MINIMAL_CONFIG = _REPO_ROOT / "examples" / "python-sdk-agent" / "minimal.cordis.yml" - -# The config must include the JSON-RPC serving plugin. -_CORDIS_YML = """\ -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './sessions' -- id: session-checkpoints - name: '@deepseek-ai/dsh-session-checkpoint-policy' -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - cwd: '.' -- id: todo - name: '@deepseek-ai/dsh-tool-todo' - config: - allowParallelInProgress: true -""" -def _launch_args(mode: str) -> tuple[str, ...]: +def _select_mode(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: try: - return resolve_bundled_launch_args(mode) + resolve_bundled_launch_args(mode) except FileNotFoundError as exc: pytest.skip(f"bundled {mode}-mode runtime unavailable on this machine: {exc}") + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, mode) -def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: +def _client(tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch, *patches: Path) -> HarnessClient: + _select_mode(mode, monkeypatch) return HarnessClient( HarnessConfig( - launch_args_override=launch_args, + dsh_home=str(tmp_path / "home"), + patches=tuple(str(patch) for patch in patches), cwd=str(tmp_path), env={ - "DSH_CORDIS_CONFIG": "./cordis.yml", - "DSH_SESSION_ROOT": str(tmp_path / "sessions"), - "DSH_CWD": str(tmp_path), # The lazily mounted adapter requires a key even without a model call. "DEEPSEEK_API_KEY": "sk-dummy-for-boot", "DEEPSEEK_BASE_URL": "http://127.0.0.1:9", + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", }, request_timeout_seconds=120, ) @@ -71,34 +46,39 @@ def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: @pytest.mark.parametrize("mode", _MODES) -def test_bundled_runtime_boots_a_cordis_config(tmp_path: Path, mode: str) -> None: - launch_args = _launch_args(mode) - (tmp_path / "cordis.yml").write_text(_CORDIS_YML) - - with _client(tmp_path, launch_args) as client: +def test_bundled_runtime_boots_the_sdk_profile( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + with _client(tmp_path, mode, monkeypatch) as client: init = client.initialize(provider="deepseek-official", cwd=str(tmp_path), model="deepseek-v4-pro") assert init.serverInfo is not None assert init.serverInfo.name == "deepseek-harness-sdk-runtime" + profile = json.loads((tmp_path / "home" / "profiles" / "sdk" / "package.json").read_text()) + assert profile["dsh"]["profile"]["bundles"] == [ + "@deepseek-ai/dsh-base", + "@deepseek-ai/dsh-sdk-app", + ] @pytest.mark.parametrize("mode", _MODES) -def test_python_sdk_boots_minimal_jsonrpc_config(tmp_path: Path, mode: str) -> None: - launch_args = _launch_args(mode) - model = "minimal-environment-model" +def test_python_sdk_applies_an_ordered_profile_patch( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + _select_mode(mode, monkeypatch) + patch = tmp_path / "persona.patch.yml" + patch.write_text(json.dumps([{ + "id": "system-prompt", + "config": {"persona": "Python SDK ordered patch marker."}, + }])) harness = DeepSeekHarness( - model=model, + model="deepseek-v4-pro", cwd=str(tmp_path), - session_root=str(tmp_path / "sessions"), - cordis=str(_MINIMAL_CONFIG), - env={ - "DSH_MODEL": model, - "DSH_CONTEXT_WINDOW": "1000000", - "DSH_SYSTEM_PROMPT": "You are the Python SDK minimal boot test agent.", - }, + dsh_home=str(tmp_path / "home"), + patches=(str(patch),), + env={"DSH_PERMISSION_MODE": "danger-full-access"}, api_key="sk-dummy-for-boot", base_url="http://127.0.0.1:9", - launch_args_override=launch_args, request_timeout_seconds=120, ) @@ -107,42 +87,20 @@ def test_python_sdk_boots_minimal_jsonrpc_config(tmp_path: Path, mode: str) -> N @pytest.mark.parametrize("mode", _MODES) -def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: str) -> None: - launch_args = _launch_args(mode) - (tmp_path / "cordis.yml").write_text( - "- id: missing\n name: '@deepseek-ai/dsh-does-not-exist'\n" - ) +def test_bundled_runtime_surfaces_unbundled_plugin_failure( + tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch +) -> None: + patch = tmp_path / "missing.patch.yml" + patch.write_text(json.dumps([{ + "insert": [{"id": "missing", "name": "@deepseek-ai/dsh-does-not-exist"}], + }])) - client = _client(tmp_path, launch_args) + client = _client(tmp_path, mode, monkeypatch, patch) client.start() try: - with pytest.raises((TransportClosedError, TimeoutError)) as excinfo: + with pytest.raises((JsonRpcError, TransportClosedError, TimeoutError)) as excinfo: client.initialize(provider="deepseek-official", cwd=str(tmp_path), model="deepseek-v4-pro") finally: client.close() assert "@deepseek-ai/dsh-does-not-exist" in str(excinfo.value) - - -@pytest.mark.parametrize("mode", _MODES) -@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) -def test_zero_config_run_injects_bundled_default_cordis_config( - tmp_path: Path, mode: str, ambient_config: str | None, monkeypatch: pytest.MonkeyPatch -) -> None: - _launch_args(mode) # skip early when this carrier is unavailable - monkeypatch.setenv("DSH_RUNTIME_MODE", mode) - if ambient_config is None: - monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) - else: - monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) - - harness = DeepSeekHarness( - model="deepseek-v4-pro", - cwd=str(tmp_path), - session_root=str(tmp_path / "sessions"), - api_key="sk-dummy-for-boot", - base_url="http://127.0.0.1:9", - request_timeout_seconds=120, - ) - with harness: - pass diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 51c9dacb31..f1190407c9 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -9,7 +9,8 @@ from pathlib import Path import pytest -from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification, SdkProtocolError +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification, RunResult, SdkProtocolError +from deepseek_harness.errors import JsonRpcError def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: @@ -95,9 +96,7 @@ for line in sys.stdin: model="deepseek-v4-flash", max_tokens=4096, cwd=str(tmp_path), - cordis=str(tmp_path / "cordis.yml"), - session_root=str(tmp_path / "sessions"), - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), env={ "ENV_DUMP": str(env_dump), "INIT_DUMP": str(init_dump), @@ -113,9 +112,9 @@ for line in sys.stdin: dumped_env = json.loads(env_dump.read_text()) assert dumped_env["DEEPSEEK_API_KEY"] == "env-key" assert dumped_env["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:4321" - assert dumped_env["DSH_CWD"] == str(tmp_path) - assert dumped_env["DSH_SESSION_ROOT"] == str(tmp_path / "sessions") - assert dumped_env["DSH_CORDIS_CONFIG"] == str(tmp_path / "cordis.yml") + assert dumped_env["DSH_CWD"] is None + assert dumped_env["DSH_SESSION_ROOT"] is None + assert dumped_env["DSH_CORDIS_CONFIG"] is None assert json.loads(init_dump.read_text()) == { "cwd": str(tmp_path), "provider": "deepseek-official", @@ -150,7 +149,7 @@ for line in sys.stdin: seen: list[str] = [] with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: session = harness.start_session("main") @@ -188,7 +187,7 @@ for line in sys.stdin: ) with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: with pytest.raises( @@ -224,7 +223,7 @@ for line in sys.stdin: with DeepSeekHarness( cwd=".", runtime_cwd=".", - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), env={"CAPTURE": str(capture)}, ): pass @@ -232,7 +231,7 @@ for line in sys.stdin: expected = str(tmp_path.resolve()) assert json.loads(capture.read_text()) == { "process": expected, - "environment": expected, + "environment": None, "wire": expected, } @@ -263,7 +262,7 @@ for line in sys.stdin: ) with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: result = harness.run("spawn a helper", session_id="main") @@ -312,7 +311,7 @@ for line in sys.stdin: seen: list[str] = [] with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: result = harness.run( @@ -367,7 +366,7 @@ for line in sys.stdin: ) with DeepSeekHarness( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), cwd=str(tmp_path), ) as harness: result = harness.run("stay in your lane", session_id="main") @@ -401,7 +400,7 @@ for line in sys.stdin: """.strip() ) - with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: result = harness.run("one turn", session_id="main") assert harness.client._notifications.qsize() == 0 @@ -441,7 +440,7 @@ for line in sys.stdin: """.strip() ) - with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: first = harness.run("first turn", session_id="main") second = harness.run("second turn", session_id="main") @@ -473,7 +472,7 @@ for line in sys.stdin: ) with HarnessClient( - HarnessConfig(launch_args_override=(sys.executable, str(script))) + HarnessConfig(_launch_args=(sys.executable, str(script))) ) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -612,7 +611,7 @@ for line in sys.stdin: def broken_filter(_notification: object) -> bool: raise RuntimeError("bad notification filter") - with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with ( client.subscribe_notifications(broken_filter) as broken, @@ -649,7 +648,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with pytest.raises(ValueError): client.session_prompt("main", [{"type": "text", "text": "fix it"}]) @@ -677,7 +676,7 @@ for line in sys.stdin: ) with HarnessClient( - HarnessConfig(launch_args_override=(sys.executable, str(script))) + HarnessConfig(_launch_args=(sys.executable, str(script))) ) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") @@ -711,7 +710,7 @@ for line in sys.stdin: ) with HarnessClient( - HarnessConfig(launch_args_override=(sys.executable, str(script))) + HarnessConfig(_launch_args=(sys.executable, str(script))) ) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -731,7 +730,7 @@ time.sleep(60) with HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), request_timeout_seconds=0.1, ) ) as client: @@ -767,7 +766,7 @@ for line in sys.stdin: client = HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), shutdown_timeout_seconds=0.1, ) ) @@ -792,6 +791,7 @@ import sys for line in sys.stdin: msg = json.loads(line) if msg.get("method") == "initialize": + print("initialize diagnostic", file=sys.stderr, flush=True) print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32000, "message": "bad initialize"}}), flush=True) elif msg.get("method") == "shutdown": print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) @@ -799,14 +799,16 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) client.start() proc = client._proc assert proc is not None - with pytest.raises(Exception, match="bad initialize"): + with pytest.raises(JsonRpcError, match="bad initialize") as excinfo: client.initialize(provider="deepseek-official", cwd=".", model="dsagent") + assert excinfo.value.code == -32000 + assert "initialize diagnostic" in str(excinfo.value) assert proc.wait(timeout=1) is not None assert client._proc is None @@ -824,6 +826,16 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: assert "max_tokens" in inspect.signature(HarnessClient.initialize).parameters assert "client_name" not in HarnessConfig.__dataclass_fields__ assert "client_version" not in HarnessConfig.__dataclass_fields__ + assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set( + DeepSeekHarnessConfig.__dataclass_fields__ + ) + assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set( + HarnessConfig.__dataclass_fields__ + ) + for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): + assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ + assert removed not in HarnessConfig.__dataclass_fields__ + assert "session_root" not in RunResult.__dataclass_fields__ def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None: @@ -845,7 +857,7 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) client.start() client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") client.close() @@ -865,7 +877,7 @@ sys.exit(42) with HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), request_timeout_seconds=2, ) ) as client: @@ -897,7 +909,7 @@ with open(os.environ["SEEN"], "w") as seen: with HarnessClient( HarnessConfig( - launch_args_override=(sys.executable, str(script)), + _launch_args=(sys.executable, str(script)), env={"SEEN": str(output)}, ) ) as client: @@ -915,21 +927,22 @@ with open(os.environ["SEEN"], "w") as seen: json.loads(line) -def _install_fake_bundled_runtime( +def _install_fake_bundled_dsh( tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> Path: - """Install a fake runtime package that records config and serves lifecycle calls. - - Returns the fake bundled default config path. - """ - runtime = tmp_path / "dsh-jsonrpc-agent" +) -> None: + """Install a fake runtime package that records dsh argv and serves lifecycle calls.""" + runtime = tmp_path / "dsh.py" runtime.write_text( - """#!/usr/bin/env python3 + """ import json import os import sys -json.dump({"DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG")}, open(os.environ["ENV_DUMP"], "w")) +json.dump({ + "argv": sys.argv[1:], + "DSH_HOME": os.environ.get("DSH_HOME"), + "DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG"), +}, open(os.environ["ENV_DUMP"], "w")) for line in sys.stdin: msg = json.loads(line) if msg.get("method") == "initialize": @@ -939,58 +952,75 @@ for line in sys.stdin: break """.strip() ) - runtime.chmod(0o755) - default_config = tmp_path / "default-cordis.yml" module_dir = tmp_path / "deepseek_harness_runtime" module_dir.mkdir() (module_dir / "__init__.py").write_text( f""" def resolve_bundled_launch_args(mode=None): - return ({str(runtime)!r},) - - -def bundled_default_config_path(): - return {str(default_config)!r} + return ({sys.executable!r}, {str(runtime)!r}) """.strip() ) monkeypatch.syspath_prepend(str(tmp_path)) monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) - return default_config -@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) -def test_client_default_launch_uses_bundled_runtime_and_injects_default_config( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ambient_config: str | None -) -> None: - env_dump = tmp_path / "env.json" - default_config = _install_fake_bundled_runtime(tmp_path, monkeypatch) - if ambient_config is None: - monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) - else: - monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) - - with HarnessClient(HarnessConfig(env={"ENV_DUMP": str(env_dump)})) as client: - init = client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro") - - assert init.serverInfo.name == "bundled-runtime" - assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == str(default_config) - - -def test_client_respects_explicit_config_over_bundled_default( +def test_client_default_launch_uses_bundled_dsh_sdk_profile_and_explicit_home( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: env_dump = tmp_path / "env.json" - _install_fake_bundled_runtime(tmp_path, monkeypatch) + home = tmp_path / "home" + patch = tmp_path / "sdk.patch.yml" + patch.write_text("[]\n") + _install_fake_bundled_dsh(tmp_path, monkeypatch) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("DSH_HOME", str(tmp_path / "ambient-home")) monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + with HarnessClient(HarnessConfig( + profile="sdk", + patches=("sdk.patch.yml",), + dsh_home=str(home), + env={"ENV_DUMP": str(env_dump), "DSH_HOME": str(tmp_path / "env-home")}, + )) as client: + init = client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro") + + assert init.serverInfo.name == "bundled-runtime" + assert json.loads(env_dump.read_text()) == { + "argv": ["--profile", "sdk", "--patch", str(patch)], + "DSH_HOME": str(home), + "DSH_CORDIS_CONFIG": None, + } + + +def test_client_accepts_explicit_environment_dsh_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + env_dump = tmp_path / "env.json" + home = tmp_path / "environment-home" + _install_fake_bundled_dsh(tmp_path, monkeypatch) + with HarnessClient( - HarnessConfig(env={"ENV_DUMP": str(env_dump), "DSH_CORDIS_CONFIG": "./explicit.yml"}) + HarnessConfig(profile="custom", env={"ENV_DUMP": str(env_dump), "DSH_HOME": str(home)}) ) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="deepseek-v4-pro") - assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == "./explicit.yml" + assert json.loads(env_dump.read_text()) == { + "argv": ["--profile", "custom"], + "DSH_HOME": str(home), + "DSH_CORDIS_CONFIG": None, + } + + +def test_client_rejects_an_implicit_default_dsh_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_bundled_dsh(tmp_path, monkeypatch) + monkeypatch.delenv("DSH_HOME", raising=False) + + with pytest.raises(ValueError, match="explicit dsh_home or non-empty DSH_HOME"): + HarnessClient(HarnessConfig(env={})).start() def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: @@ -998,4 +1028,4 @@ def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.M monkeypatch.setattr(sys, "path", []) with pytest.raises(FileNotFoundError, match="Install deepseek-harness-runtime-bin"): - HarnessClient().start() + HarnessClient(HarnessConfig(dsh_home="/explicit/home")).start() From 01da0437378596c640fd74e34e37fb83e75e8334 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:34 +0800 Subject: [PATCH 24/42] test(python): prove installed dsh profile customization Migrate the packaged-runtime smoke inventory from complete Cordis trees to the sdk profile plus ordered patches. Preserve the focused minimal and advanced behaviors, update the generated durable snapshots for explicit permission events and the smaller RunResult, and keep worker, MCP, ripgrep, PTY/editor, direct JSON-RPC, and real-provider coverage. Add an installed-only external bundle scenario that invokes the wheel's dsh plugin command with a local file package, verifies profile manifest reconciliation, imports @deepseek-ai/cordis as a peer, asserts the packaged proxy returns the exact host Context instance, and proves its system-prompt contribution reaches the model. Migrate the repository source e2e and runnable minimal example to the same profile grammar. --- examples/python-sdk-agent/minimal.patch.yml | 68 ++ examples/python-sdk-agent/minimal.py | 19 +- .../tests/keyless-smoke.e2e.ts | 72 +- .../python-sdk-agent/tests/keyless.patch.yml | 38 + scripts/smoke-python-runtime.py | 510 ++++++++--- .../advanced/result.json | 813 ++++++++++-------- .../advanced/session.1.jsonl | 11 +- .../advanced/session.2.jsonl | 11 +- .../advanced/session.jsonl | 46 +- .../restart/requests.json | 8 + .../python-sdk-single-exe/restart/result.json | 94 +- .../restart/session.1.jsonl | 10 +- .../restart/session.2.jsonl | 10 +- 13 files changed, 1101 insertions(+), 609 deletions(-) create mode 100644 examples/python-sdk-agent/minimal.patch.yml create mode 100644 examples/python-sdk-agent/tests/keyless.patch.yml diff --git a/examples/python-sdk-agent/minimal.patch.yml b/examples/python-sdk-agent/minimal.patch.yml new file mode 100644 index 0000000000..be3f292cc0 --- /dev/null +++ b/examples/python-sdk-agent/minimal.patch.yml @@ -0,0 +1,68 @@ +# Minimal Python SDK overlay for `dsh --profile sdk`: keep only persistent +# Bash and the string-replacement editor, with no runtime-context prompt or +# compaction. The profile still owns JSON-RPC serving and persistence. + +- id: system-prompt + config: + includeHarnessIdentity: false + includeRuntimeContext: false + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + +- id: agent-instructions + disabled: true +- id: skill-filesystem + disabled: true +- id: tool-skill + disabled: true +- id: tool-bash + disabled: true +- id: tool-jobs + disabled: true +- id: tool-fs + disabled: true +- id: tool-fs-search + disabled: true +- id: tool-subagent-control + disabled: true +- id: tool-subagent-list-agents + disabled: true +- id: tool-subagent + disabled: true +- id: tool-subagent-fork + disabled: true +- id: tool-subagent-report + disabled: true +- id: tool-workflow + disabled: true +- id: tool-todo + disabled: true +- id: tool-goal + disabled: true +- id: tool-ralph + disabled: true +- id: tool-web + disabled: true +- id: plan-mode + disabled: true +- id: compaction-basic + disabled: true +- id: command-compact + disabled: true +- id: tool-result-pruner + disabled: true + +- id: tool-str-replace-editor + config: + maxOutputChars: 16000 + +- insert: + - id: pty + name: '@deepseek-ai/dsh-terminal' + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + config: + timeoutMs: 300000 + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 diff --git a/examples/python-sdk-agent/minimal.py b/examples/python-sdk-agent/minimal.py index e94b02b7d8..255a0dd3c0 100644 --- a/examples/python-sdk-agent/minimal.py +++ b/examples/python-sdk-agent/minimal.py @@ -10,30 +10,39 @@ from pathlib import Path from deepseek_harness import DeepSeekHarness -CONFIG = Path(__file__).with_name("minimal.cordis.yml") +PATCH = Path(__file__).with_name("minimal.patch.yml") def main() -> None: """Parse one task and print the agent's final response.""" parser = argparse.ArgumentParser() + configured_home = os.environ.get("DSH_HOME", "") parser.add_argument("prompt", help="Task for the minimal agent") parser.add_argument("--workspace", type=Path, default=Path.cwd()) - parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions")) + parser.add_argument( + "--dsh-home", + type=Path, + default=Path(configured_home) if configured_home.strip() else None, + ) + parser.add_argument("--profile", default="sdk") parser.add_argument("--session-id") parser.add_argument("--provider", default="deepseek-official") parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) parser.add_argument("--max-tokens", type=int) args = parser.parse_args() + if args.dsh_home is None: + parser.error("--dsh-home or a non-empty DSH_HOME is required") workspace = args.workspace.resolve() - session_root = args.session_root.resolve() + dsh_home = args.dsh_home.resolve() with DeepSeekHarness( provider=args.provider, model=args.model, max_tokens=args.max_tokens, cwd=str(workspace), - session_root=str(session_root), - cordis=str(CONFIG.resolve()), + dsh_home=str(dsh_home), + profile=args.profile, + patches=(str(PATCH.resolve()),), ) as harness: result = harness.run(args.prompt, session_id=args.session_id) print(result.final_response) diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 99897079ef..c0aabb48b8 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -8,8 +8,8 @@ import { zstdDecompress } from 'node:zlib' import { execa } from 'execa' import { describe, expect, it } from 'vitest' -const binScript = fileURLToPath(new URL('../../../packages/sdk/python-runtime/src/packaged-bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) +const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -45,7 +45,7 @@ function waitForLine( }) } -describe('Python SDK runtime carrier keyless smoke', () => { +describe('Python SDK dsh profile keyless smoke', () => { it.each([ { label: 'reports max-token turns with the default mapping config', envValue: undefined }, { label: 'reports max-token turns with mapping enabled through env', envValue: 'true' }, @@ -73,16 +73,20 @@ describe('Python SDK runtime carrier keyless smoke', () => { // execa owns spawn, the deadline, and exit settlement around it. const child = execa(process.execPath, [ '--import', - 'tsx', + 'tsx/esm', binScript, - configPath, + '--profile', + 'sdk', + '--patch', + patchPath, ], { cwd: repoRoot, env: { + DSH_HOME: join(root, '.dsh'), + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - DSH_CWD: root, - DSH_SESSION_ROOT: join(root, '.sessions'), ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), }, timeout: 35_000, @@ -149,6 +153,7 @@ describe('Python SDK runtime carrier keyless smoke', () => { 'bash', 'edit', 'read', + 'read_image', 'subagent', 'todo_write', 'write', @@ -159,7 +164,7 @@ describe('Python SDK runtime carrier keyless smoke', () => { expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) const exit = await child expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) - const sessionsRoot = join(root, '.sessions') + const sessionsRoot = join(root, '.dsh', 'sessions') const files = await readdir(sessionsRoot, { recursive: true }) const log = files.find(file => file.endsWith('.jsonl.zstd')) expect(log).toBeDefined() @@ -176,27 +181,36 @@ describe('Python SDK runtime carrier keyless smoke', () => { }, 40_000) it('rejects an invalid max-token success env value', async () => { - const { exitCode, stdout, stderr } = await execa(process.execPath, [ - '--import', - 'tsx', - binScript, - configPath, - ], { - cwd: repoRoot, - env: { - DEEPSEEK_API_KEY: 'keyless-smoke-no-call', - DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', - }, - stdin: 'ignore', - timeout: 25_000, - killSignal: 'SIGKILL', - reject: false, - }) + const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-')) + try { + const { exitCode, stdout, stderr } = await execa(process.execPath, [ + '--import', + 'tsx/esm', + binScript, + '--profile', + 'sdk', + '--patch', + patchPath, + ], { + cwd: repoRoot, + env: { + DSH_HOME: join(root, '.dsh'), + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', + }, + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + }) - expect(exitCode, stderr).toBe(1) - expect(stdout).toBe('') - expect(stderr).toContain('plugin tree failed to load') - expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)') - expect(stderr).toContain('sometimes') + expect(exitCode, stderr).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('plugin tree failed to load') + expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)') + expect(stderr).toContain('sometimes') + } finally { + await rm(root, { recursive: true, force: true }) + } }, 30_000) }) diff --git a/examples/python-sdk-agent/tests/keyless.patch.yml b/examples/python-sdk-agent/tests/keyless.patch.yml new file mode 100644 index 0000000000..71b5ec6ebd --- /dev/null +++ b/examples/python-sdk-agent/tests/keyless.patch.yml @@ -0,0 +1,38 @@ +# Preserve the focused SDK test roster over the shipped sdk profile. + +- id: agent-instructions + disabled: true +- id: tool-jobs + disabled: true +- id: tool-fs-search + disabled: true +- id: skill-filesystem + disabled: true +- id: tool-skill + disabled: true +- id: tool-str-replace-editor + disabled: true +- id: tool-subagent-control + disabled: true +- id: tool-subagent-list-agents + disabled: true +- id: tool-subagent-fork + disabled: true +- id: tool-subagent-report + disabled: true +- id: tool-workflow + disabled: true +- id: tool-goal + disabled: true +- id: plan-mode + disabled: true +- id: tool-ralph + disabled: true +- id: tool-web + disabled: true + +- id: tool-subagent + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 71e7100268..06653c5741 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -12,6 +12,7 @@ import os import queue import subprocess import sys +import sysconfig import tempfile import threading import time @@ -37,14 +38,43 @@ FS_SEARCH_TEXT = "filesystem search smoke ok" FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" MCP_PROMPT = "Exercise the packaged MCP client with one external stdio server." MCP_TEXT = "MCP client smoke ok" -MINIMAL_CORDIS = ( - Path(__file__).resolve().parent.parent / "examples" / "python-sdk-agent" / "minimal.cordis.yml" -) +PROFILE_PLUGIN_PROMPT = "Verify the Python-installed dsh profile plugin." +PROFILE_PLUGIN_TEXT = "profile plugin smoke ok" +PROFILE_PLUGIN_MARKER = "PYTHON_INSTALLED_DSH_PROFILE_PLUGIN" MINIMAL_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" ) +MINIMAL_BASH_DESCRIPTION = """Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.""" +LEGACY_CUSTOM_DISABLED_ROWS = ( + "agent-instructions", + "goal", + "goal-round-driver", + "command-goal", + "plan-mode", + "skill", + "skill-filesystem", + "tool-fs", + "tool-fs-search", + "tool-goal", + "tool-ralph", + "tool-skill", + "tool-str-replace-editor", + "tool-subagent-control", + "tool-subagent-list-agents", + "tool-subagent-fork", + "tool-subagent-report", + "tool-todo", + "tool-web", +) SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario." SNAPSHOT_SESSION_ID = "advanced-executable" SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else." @@ -95,75 +125,6 @@ RESTART_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.1.jsonl", # expected output cannot carry: the same composition emits it on macOS and not on Linux # (deepseek-harness#2488), and the file must replay on both. Everything else is compared. RUNTIME_CONTEXT_PREFIX = "Current runtime context" -CUSTOM_CORDIS = """\ -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' -- id: deepseek-llm-api-extensions - name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' -- id: session-log-deepseek - name: '@deepseek-ai/dsh-session-log-deepseek' - config: - enabled: true -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: false - skills: - enabled: false - toolBash: false - tools: - mode: both -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker-thread' -- id: subagents - name: '@deepseek-ai/dsh-subagent' -- id: subagent-spawn-in-process - name: '@deepseek-ai/dsh-subagent-spawn-in-process' - config: - providerName: spawn -- id: subagent-tool - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn -- id: workflow-engine - name: '@deepseek-ai/dsh-workflow-worker-thread' - config: - provider: spawn -- id: workflow-tool - name: '@deepseek-ai/dsh-tool-workflow' -- id: cordis-host-runner - name: '@deepseek-ai/dsh-cordis-host-runner' -- id: cordis-tool - name: '@deepseek-ai/dsh-tool-cordis' -""" -FS_SEARCH_CORDIS = """\ -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - workspaceContext: false - skills: - enabled: false - toolBash: false - toolJobs: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' -- id: fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - config: - sampleOverCapGlobResults: false -""" MCP_SERVER_SCRIPT = """\ import json import os @@ -242,28 +203,29 @@ for line in sys.stdin: """ -def mcp_cordis(server_script: Path) -> str: - """Build an external config that mounts the packaged MCP client.""" - return json.dumps([ +def write_profile_patch( + root: Path, + name: str, + sessions: Path, + patches: list[dict[str, object]], +) -> Path: + """Write one JSON-form dsh profile patch with deterministic persistence.""" + path = root / name + path.write_text(json.dumps([ { - "id": "sdk-jsonrpc-server", - "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", + "id": "session-persistence-jsonl", + "config": {"root": str(sessions), "compression": "none"}, }, - { - "id": "agent-core", - "name": "@deepseek-ai/dsh-agent-spine-demo", - "config": { - "workspaceContext": False, - "skills": {"enabled": False}, - "toolBash": False, - }, - }, - { - "id": "sessions", - "name": "@deepseek-ai/dsh-session-persistence-jsonl", - "config": {"root": "./sessions", "compression": "none"}, - }, - { + {"id": "session-telemetry-otel", "disabled": True}, + *patches, + ], indent=2)) + return path + + +def write_mcp_patch(root: Path, sessions: Path, server_script: Path) -> Path: + """Write a profile patch that mounts the packaged MCP client.""" + return write_profile_patch(root, "mcp.patch.yml", sessions, [{ + "insert": [{ "id": "mcp-fixture", "name": "@deepseek-ai/dsh-mcp-client", "config": { @@ -275,8 +237,8 @@ def mcp_cordis(server_script: Path) -> str: "failOnStartupError": True, "reconnect": {"enabled": False}, }, - }, - ], indent=2) + }], + }]) class MockModelHandler(BaseHTTPRequestHandler): @@ -364,6 +326,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: MCP_PROMPT, RESTART_FIRST_PROMPT, RESTART_SECOND_PROMPT, + PROFILE_PLUGIN_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -430,6 +393,15 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "mcp__fixture__add", {"a": 19, "b": 23}, ) + if prompt == PROFILE_PLUGIN_PROMPT: + system_text = "\n".join( + message_text(message.get("content")) + for message in messages + if isinstance(message, dict) and message.get("role") == "system" + ) + if PROFILE_PLUGIN_MARKER not in system_text: + raise AssertionError("external profile plugin contributed no model-visible marker") + return text_chunks(PROFILE_PLUGIN_TEXT) return text_chunks(EXPECTED_TEXT) @@ -710,7 +682,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-live", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "direct"), default="all", ) parser.add_argument("--exe", type=Path) @@ -725,6 +697,8 @@ def main() -> None: parser.error("--installed-wheel resolves the wheel's own runtime and cannot be combined with --exe") if args.scenario == "sdk-live" and not args.installed_wheel: parser.error("--scenario sdk-live requires --installed-wheel") + if args.scenario == "sdk-profile-plugin" and not args.installed_wheel: + parser.error("--scenario sdk-profile-plugin requires --installed-wheel") if args.installed_wheel: args.exe = assert_installed_wheel_environment() if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "sdk-restart", "direct"} and args.exe is None: @@ -759,6 +733,8 @@ def main() -> None: if args.scenario in {"all", "sdk-restart"}: assert args.exe is not None smoke_sdk_restart_snapshot(model.url, args.exe.resolve(), args.update_snapshots) + if args.installed_wheel and args.scenario in {"all", "sdk-profile-plugin"}: + smoke_sdk_profile_plugin(model.url) if args.scenario in {"all", "direct"}: assert args.exe is not None smoke_direct(model.url, args.exe.resolve()) @@ -832,7 +808,8 @@ def smoke_sdk_live() -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-live-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" marker = root / "live-api-marker.txt" session_id = "installed-wheel-live-api" create_prompt = ( @@ -847,7 +824,11 @@ def smoke_sdk_live() -> None: provider="deepseek-official", model="deepseek-v4-flash", cwd=str(root), - session_root=str(sessions), + dsh_home=str(dsh_home), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key=api_key, base_url=base_url, request_timeout_seconds=180, @@ -910,18 +891,27 @@ def smoke_sdk_default(base_url: str) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), + dsh_home=str(dsh_home), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, ) as harness: result = harness.run("reply with the smoke text", session_id="default-smoke") - assert result.final_response == EXPECTED_TEXT, result.final_response + assert result.final_response == EXPECTED_TEXT, ( + f"final={result.final_response!r} finish={result.finish_reason!r} " + f"events={[event.get('type') for event in result.events]!r} " + f"turn_end={safe_turn_end(next((event.get('data', event) for event in reversed(result.events) if event.get('type') == 'turn/end'), {}))!r}" + ) assert_zstd_session_log(sessions) @@ -930,16 +920,44 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "custom.patch.yml", sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -963,14 +981,70 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - root = Path(temporary).resolve() editor_path = root / "created.txt" prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" + disabled = [ + "agent-instructions", + "skill-filesystem", + "tool-skill", + "tool-bash", + "tool-jobs", + "tool-fs", + "tool-fs-search", + "tool-subagent-control", + "tool-subagent-list-agents", + "tool-subagent", + "tool-subagent-fork", + "tool-subagent-report", + "tool-workflow", + "tool-todo", + "tool-goal", + "tool-ralph", + "tool-web", + "plan-mode", + "compaction-basic", + "command-compact", + "tool-result-pruner", + ] + patch = write_profile_patch(root, "minimal.patch.yml", sessions, [ + { + "id": "system-prompt", + "config": { + "includeHarnessIdentity": False, + "includeRuntimeContext": False, + "persona": "You are a helpful software engineer assistant.", + }, + }, + *({"id": row_id, "disabled": True} for row_id in disabled), + {"id": "tool-str-replace-editor", "config": {"maxOutputChars": 16000}}, + {"insert": [ + {"id": "pty", "name": "@deepseek-ai/dsh-terminal"}, + { + "id": "terminal-bash", + "name": "@deepseek-ai/dsh-terminal-bash", + "config": {"timeoutMs": 300000}, + }, + { + "id": "persistent-bash", + "name": "@deepseek-ai/dsh-tool-bash-persistent", + "config": { + "timeoutMs": 300000, + "description": MINIMAL_BASH_DESCRIPTION, + }, + }, + ]}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(MINIMAL_CORDIS), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -997,16 +1071,23 @@ def smoke_sdk_fs_search(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-fs-search-") as temporary: root = Path(temporary).resolve() (root / "needle.txt").write_text(f"{FS_SEARCH_MARKER}\n") - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(FS_SEARCH_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "fs-search.patch.yml", sessions, [ + {"id": "skill-filesystem", "disabled": True}, + {"id": "tool-fs-search", "config": {"sampleOverCapGlobResults": False}}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1023,19 +1104,23 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: with tempfile.TemporaryDirectory(prefix="dsh-sdk-mcp-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" + dsh_home = root / "home" + sessions = dsh_home / "sessions" server_script = root / "mcp_server.py" server_script.write_text(MCP_SERVER_SCRIPT) - cordis = root / "cordis.yml" - cordis.write_text(mcp_cordis(server_script)) + patch = write_mcp_patch(root, sessions, server_script) discovery_log = server_script.with_suffix(".log") with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=None if executable is None else str(executable), + dsh_bin=None if executable is None else str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1052,22 +1137,131 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: assert_session_log(sessions, root, MCP_TEXT, "mcp__fixture__add", "42") +def smoke_sdk_profile_plugin(base_url: str) -> None: + """Install an external bundle through Python's dsh command and load it in the SDK.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-profile-plugin-") as temporary: + root = Path(temporary).resolve() + dsh_home = root / "home" + plugin = root / "plugin" + plugin.mkdir() + (plugin / "package.json").write_text(json.dumps({ + "name": "dsh-python-blackbox-plugin", + "version": "1.0.0", + "private": True, + "type": "module", + "exports": "./index.js", + "peerDependencies": {"@deepseek-ai/cordis": "*"}, + "dsh": {"bundle": {"patch": "./cordis.patch.yml"}}, + }, indent=2)) + (plugin / "index.js").write_text( + "import { Context } from '@deepseek-ai/cordis'\n" + "export const name = 'python-sdk-blackbox-plugin'\n" + "export const inject = ['systemPrompt']\n" + "export function apply(ctx) {\n" + " if (!(ctx instanceof Context)) throw new Error('external plugin loaded a second Cordis instance')\n" + " ctx.effect(() => ctx.systemPrompt.section({\n" + " name: 'python-sdk:blackbox-plugin',\n" + " order: 10,\n" + f" text: '{PROFILE_PLUGIN_MARKER}',\n" + " }))\n" + "}\n" + ) + (plugin / "cordis.patch.yml").write_text(json.dumps([{ + "insert": [{"id": "python-sdk-blackbox-plugin", "name": "dsh-python-blackbox-plugin"}], + }], indent=2)) + + dsh = Path(sysconfig.get_path("scripts")) / "dsh" + environment = {**os.environ, "DSH_HOME": str(dsh_home)} + installed = subprocess.run( + [str(dsh), "plugin", "--profile", "sdk", "add", f"file:{plugin}"], + cwd=root, + env=environment, + text=True, + capture_output=True, + check=False, + ) + if installed.returncode != 0: + raise AssertionError( + f"Python-installed dsh could not add the external profile plugin: " + f"stdout={installed.stdout!r} stderr={installed.stderr!r}" + ) + manifest = json.loads((dsh_home / "profiles" / "sdk" / "package.json").read_text()) + if "dsh-python-blackbox-plugin" not in manifest.get("dependencies", {}): + raise AssertionError(f"dsh plugin did not record the external dependency: {manifest}") + if "dsh-python-blackbox-plugin" not in manifest["dsh"]["profile"]["bundles"]: + raise AssertionError(f"dsh plugin did not activate the external bundle: {manifest}") + + harness = DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + dsh_home=str(dsh_home), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) + try: + with harness: + result = harness.run(PROFILE_PLUGIN_PROMPT, session_id="profile-plugin-smoke") + except Exception as error: + raise AssertionError( + f"external profile plugin runtime failed: {harness.client._runtime_diagnostics()}" + ) from error + + assert result.final_response == PROFILE_PLUGIN_TEXT, result.final_response + assert_zstd_session_log(dsh_home / "sessions") + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "snapshot.patch.yml", sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1103,9 +1297,33 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots with tempfile.TemporaryDirectory(prefix="dsh-sdk-restart-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "restart.patch.yml", sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) first_request = len(MockModelHandler.requests) def run(prompt: str, session_id: str) -> "RunResult": @@ -1113,9 +1331,13 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots provider="deepseek-official", model="smoke-model", cwd=str(root), - session_root=str(sessions), - cordis=str(cordis), - runtime_bin=str(executable), + dsh_bin=str(executable), + dsh_home=str(dsh_home), + patches=(str(patch),), + env={ + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", + }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, @@ -1155,18 +1377,22 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots def smoke_direct(base_url: str, executable: Path) -> None: with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary: root = Path(temporary).resolve() - sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(CUSTOM_CORDIS) + dsh_home = root / "home" + sessions = dsh_home / "sessions" + patch = write_profile_patch(root, "direct.patch.yml", sessions, []) environment = { **os.environ, - "DSH_CORDIS_CONFIG": str(cordis), - "DSH_SESSION_ROOT": str(sessions), - "DSH_CWD": str(root), + "DSH_HOME": str(dsh_home), + "DSH_PERMISSION_MODE": "danger-full-access", + "DSH_TELEMETRY_DISABLED": "1", "DEEPSEEK_API_KEY": "sk-keyless-smoke", "DEEPSEEK_BASE_URL": base_url, } - peer = RuntimePeer([str(executable)], root, environment) + peer = RuntimePeer( + [str(executable), "--profile", "sdk", "--patch", str(patch)], + root, + environment, + ) try: peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "provider": "deepseek-official", "model": "smoke-model"}}) peer.read_until(lambda message: message.get("id") == "initialize") @@ -1419,7 +1645,6 @@ def build_snapshot_files( {"method": notification.method, "payload": notification.payload} for notification in result.notifications ], - "session_root": result.session_root, } normalized_result = normalize_snapshot_value(result_value, replacements) files = { @@ -1461,7 +1686,6 @@ def build_restart_snapshot_files( "finish_reason": result.finish_reason, "eventTypes": [event.get("type") for event in result.events], "notificationMethods": [notification.method for notification in result.notifications], - "session_root": result.session_root, } for result in (first, second) ] diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 0889fd6b9f..20aecae839 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -4,7 +4,7 @@ "events": [ { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -28,7 +28,7 @@ }, { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -36,7 +36,7 @@ }, { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -47,7 +47,7 @@ }, { "type": "step/start", - "seq": 3, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -56,7 +56,7 @@ }, { "type": "user/message", - "seq": 4, + "seq": 7, "time": 0, "data": { "content": [ @@ -73,14 +73,45 @@ }, "surfaceOp": "append" }, + { + "type": "user/message", + "seq": 8, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." + } + ], + "source": { + "kind": "plugin", + "plugin": "@deepseek-ai/dsh-system-prompt", + "form": "snapshot", + "sections": [ + { + "name": "sandbox:policy", + "text": "Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations." + }, + { + "name": "approval:policy", + "text": "Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." + } + ] + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + }, { "type": "session/title", - "seq": 5, + "seq": 9, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 4 + 7 ], "source": { "kind": "fallback" @@ -89,7 +120,7 @@ }, { "type": "request/header", - "seq": 6, + "seq": 10, "time": 0, "data": { "header": { @@ -125,7 +156,7 @@ }, { "type": "request/context", - "seq": 7, + "seq": 11, "time": 0, "data": { "provider": "deepseek-official", @@ -135,16 +166,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 8, + "seq": 12, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 7 + "throughSeq": 11 } }, { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -158,7 +189,7 @@ }, { "type": "assistant/chunk", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -174,7 +205,7 @@ }, { "type": "assistant/chunk", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -193,7 +224,7 @@ }, { "type": "assistant/chunk", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -209,7 +240,7 @@ }, { "type": "assistant/chunk", - "seq": 13, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -224,7 +255,7 @@ }, { "type": "assistant/message", - "seq": 14, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -252,17 +283,17 @@ } }, "sourceEventSeqs": [ - 9, - 10, - 11, - 12, - 13 + 13, + 14, + 15, + 16, + 17 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 15, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -274,7 +305,7 @@ }, { "type": "tool/result", - "seq": 16, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -306,13 +337,13 @@ } }, "sourceEventSeqs": [ - 15 + 19 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 17, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -321,7 +352,7 @@ }, { "type": "step/start", - "seq": 18, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -330,16 +361,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 19, + "seq": 23, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 18 + "throughSeq": 22 } }, { "type": "assistant/chunk", - "seq": 20, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -353,7 +384,7 @@ }, { "type": "assistant/chunk", - "seq": 21, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -369,7 +400,7 @@ }, { "type": "assistant/chunk", - "seq": 22, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -388,7 +419,7 @@ }, { "type": "assistant/chunk", - "seq": 23, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -404,7 +435,7 @@ }, { "type": "assistant/chunk", - "seq": 24, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -419,7 +450,7 @@ }, { "type": "assistant/message", - "seq": 25, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -447,17 +478,17 @@ } }, "sourceEventSeqs": [ - 20, - 21, - 22, - 23, - 24 + 24, + 25, + 26, + 27, + 28 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 26, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -469,7 +500,7 @@ }, { "type": "tool/result", - "seq": 27, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -502,13 +533,13 @@ } }, "sourceEventSeqs": [ - 26 + 30 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 28, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -517,7 +548,7 @@ }, { "type": "step/start", - "seq": 29, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -526,7 +557,7 @@ }, { "type": "request/header", - "seq": 30, + "seq": 34, "time": 0, "data": { "header": { @@ -563,16 +594,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 31, + "seq": 35, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 30 + "throughSeq": 34 } }, { "type": "assistant/chunk", - "seq": 32, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -586,7 +617,7 @@ }, { "type": "assistant/chunk", - "seq": 33, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -602,7 +633,7 @@ }, { "type": "assistant/chunk", - "seq": 34, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -621,7 +652,7 @@ }, { "type": "assistant/chunk", - "seq": 35, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -637,7 +668,7 @@ }, { "type": "assistant/chunk", - "seq": 36, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -652,7 +683,7 @@ }, { "type": "assistant/message", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -680,17 +711,17 @@ } }, "sourceEventSeqs": [ - 32, - 33, - 34, - 35, - 36 + 36, + 37, + 38, + 39, + 40 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 38, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -702,7 +733,7 @@ }, { "type": "tool/code-dispatch-start", - "seq": 39, + "seq": 43, "time": 0, "data": { "rootCallId": "advanced-code", @@ -716,7 +747,7 @@ }, { "type": "tool/code-dispatch", - "seq": 40, + "seq": 44, "time": 0, "data": { "rootCallId": "advanced-code", @@ -737,7 +768,7 @@ }, { "type": "tool/result", - "seq": 41, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -765,13 +796,13 @@ } }, "sourceEventSeqs": [ - 38 + 42 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 42, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -780,7 +811,7 @@ }, { "type": "step/start", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -789,16 +820,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 44, + "seq": 48, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 43 + "throughSeq": 47 } }, { "type": "assistant/chunk", - "seq": 45, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -812,7 +843,7 @@ }, { "type": "assistant/chunk", - "seq": 46, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -828,7 +859,7 @@ }, { "type": "assistant/chunk", - "seq": 47, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -847,7 +878,7 @@ }, { "type": "assistant/chunk", - "seq": 48, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -863,7 +894,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -878,7 +909,7 @@ }, { "type": "assistant/message", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -906,17 +937,17 @@ } }, "sourceEventSeqs": [ - 45, - 46, - 47, - 48, - 49 + 49, + 50, + 51, + 52, + 53 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -928,7 +959,7 @@ }, { "type": "tool/result", - "seq": 52, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -956,13 +987,13 @@ } }, "sourceEventSeqs": [ - 51 + 55 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 53, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -971,7 +1002,7 @@ }, { "type": "step/start", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -980,16 +1011,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 55, + "seq": 59, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 54 + "throughSeq": 58 } }, { "type": "assistant/chunk", - "seq": 56, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -1003,7 +1034,7 @@ }, { "type": "assistant/chunk", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -1019,7 +1050,7 @@ }, { "type": "assistant/chunk", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1038,7 +1069,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1054,7 +1085,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1069,7 +1100,7 @@ }, { "type": "assistant/message", - "seq": 61, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1097,17 +1128,17 @@ } }, "sourceEventSeqs": [ - 56, - 57, - 58, - 59, - 60 + 60, + 61, + 62, + 63, + 64 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 62, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1119,7 +1150,7 @@ }, { "type": "tool-workflow/run-start", - "seq": 63, + "seq": 67, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1128,7 +1159,7 @@ }, { "type": "tool-workflow/agent-start", - "seq": 64, + "seq": 68, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1140,7 +1171,7 @@ }, { "type": "tool-workflow/agent-end", - "seq": 65, + "seq": 69, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1150,7 +1181,7 @@ }, { "type": "tool-workflow/run-end", - "seq": 66, + "seq": 70, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -1159,7 +1190,7 @@ }, { "type": "tool/result", - "seq": 67, + "seq": 71, "time": 0, "data": { "turn": 1, @@ -1187,13 +1218,13 @@ } }, "sourceEventSeqs": [ - 62 + 66 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 68, + "seq": 72, "time": 0, "data": { "turn": 1, @@ -1202,7 +1233,7 @@ }, { "type": "step/start", - "seq": 69, + "seq": 73, "time": 0, "data": { "turn": 1, @@ -1211,16 +1242,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 70, + "seq": 74, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 69 + "throughSeq": 73 } }, { "type": "assistant/chunk", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, @@ -1234,7 +1265,7 @@ }, { "type": "assistant/chunk", - "seq": 72, + "seq": 76, "time": 0, "data": { "turn": 1, @@ -1250,7 +1281,7 @@ }, { "type": "assistant/chunk", - "seq": 73, + "seq": 77, "time": 0, "data": { "turn": 1, @@ -1269,7 +1300,7 @@ }, { "type": "assistant/chunk", - "seq": 74, + "seq": 78, "time": 0, "data": { "turn": 1, @@ -1285,7 +1316,7 @@ }, { "type": "assistant/chunk", - "seq": 75, + "seq": 79, "time": 0, "data": { "turn": 1, @@ -1300,7 +1331,7 @@ }, { "type": "assistant/message", - "seq": 76, + "seq": 80, "time": 0, "data": { "turn": 1, @@ -1328,17 +1359,17 @@ } }, "sourceEventSeqs": [ - 71, - 72, - 73, - 74, - 75 + 75, + 76, + 77, + 78, + 79 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 77, + "seq": 81, "time": 0, "data": { "turn": 1, @@ -1350,7 +1381,7 @@ }, { "type": "tool/result", - "seq": 78, + "seq": 82, "time": 0, "data": { "turn": 1, @@ -1378,13 +1409,13 @@ } }, "sourceEventSeqs": [ - 77 + 81 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 79, + "seq": 83, "time": 0, "data": { "turn": 1, @@ -1393,7 +1424,7 @@ }, { "type": "step/start", - "seq": 80, + "seq": 84, "time": 0, "data": { "turn": 1, @@ -1402,7 +1433,7 @@ }, { "type": "request/header", - "seq": 81, + "seq": 85, "time": 0, "data": { "header": { @@ -1438,16 +1469,16 @@ }, { "type": "session-log-deepseek/delivery-accepted", - "seq": 82, + "seq": 86, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 81 + "throughSeq": 85 } }, { "type": "assistant/chunk", - "seq": 83, + "seq": 87, "time": 0, "data": { "turn": 1, @@ -1461,7 +1492,7 @@ }, { "type": "assistant/chunk", - "seq": 84, + "seq": 88, "time": 0, "data": { "turn": 1, @@ -1475,7 +1506,7 @@ }, { "type": "assistant/chunk", - "seq": 85, + "seq": 89, "time": 0, "data": { "turn": 1, @@ -1492,7 +1523,7 @@ }, { "type": "assistant/chunk", - "seq": 86, + "seq": 90, "time": 0, "data": { "turn": 1, @@ -1508,7 +1539,7 @@ }, { "type": "assistant/chunk", - "seq": 87, + "seq": 91, "time": 0, "data": { "turn": 1, @@ -1523,7 +1554,7 @@ }, { "type": "assistant/message", - "seq": 88, + "seq": 92, "time": 0, "data": { "turn": 1, @@ -1549,17 +1580,17 @@ } }, "sourceEventSeqs": [ - 83, - 84, - 85, - 86, - 87 + 87, + 88, + 89, + 90, + 91 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 89, + "seq": 93, "time": 0, "data": { "turn": 1, @@ -1568,7 +1599,7 @@ }, { "type": "turn/end", - "seq": 90, + "seq": 94, "time": 0, "data": { "turn": 1, @@ -1585,7 +1616,7 @@ "sessionId": "{{parent}}", "event": { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -1622,7 +1653,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -1636,7 +1667,7 @@ "sessionId": "{{parent}}", "event": { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -1653,7 +1684,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 3, + "seq": 6, "time": 0, "data": { "turn": 1, @@ -1668,7 +1699,7 @@ "sessionId": "{{parent}}", "event": { "type": "user/message", - "seq": 4, + "seq": 7, "time": 0, "data": { "content": [ @@ -1687,18 +1718,55 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "user/message", + "seq": 8, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." + } + ], + "source": { + "kind": "plugin", + "plugin": "@deepseek-ai/dsh-system-prompt", + "form": "snapshot", + "sections": [ + { + "name": "sandbox:policy", + "text": "Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations." + }, + { + "name": "approval:policy", + "text": "Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." + } + ] + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{parent}}", "event": { "type": "session/title", - "seq": 5, + "seq": 9, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 4 + 7 ], "source": { "kind": "fallback" @@ -1713,7 +1781,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 6, + "seq": 10, "time": 0, "data": { "header": { @@ -1755,7 +1823,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/context", - "seq": 7, + "seq": 11, "time": 0, "data": { "provider": "deepseek-official", @@ -1771,11 +1839,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 8, + "seq": 12, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 7 + "throughSeq": 11 } } } @@ -1786,7 +1854,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1806,7 +1874,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1828,7 +1896,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -1853,7 +1921,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1875,7 +1943,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1896,7 +1964,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 14, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -1924,11 +1992,11 @@ } }, "sourceEventSeqs": [ - 9, - 10, - 11, - 12, - 13 + 13, + 14, + 15, + 16, + 17 ], "surfaceOp": "append" } @@ -1940,7 +2008,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 15, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -1958,7 +2026,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 16, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1990,7 +2058,7 @@ } }, "sourceEventSeqs": [ - 15 + 19 ], "surfaceOp": "append" } @@ -2002,7 +2070,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 17, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -2017,7 +2085,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 18, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -2032,11 +2100,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 19, + "seq": 23, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 18 + "throughSeq": 22 } } } @@ -2047,7 +2115,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 20, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -2067,7 +2135,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 21, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -2089,7 +2157,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 22, + "seq": 26, "time": 0, "data": { "turn": 1, @@ -2114,7 +2182,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 23, + "seq": 27, "time": 0, "data": { "turn": 1, @@ -2136,7 +2204,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 24, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -2157,7 +2225,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 25, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -2185,11 +2253,11 @@ } }, "sourceEventSeqs": [ - 20, - 21, - 22, - 23, - 24 + 24, + 25, + 26, + 27, + 28 ], "surfaceOp": "append" } @@ -2201,7 +2269,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 26, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -2219,7 +2287,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 27, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -2252,7 +2320,7 @@ } }, "sourceEventSeqs": [ - 26 + 30 ], "surfaceOp": "append" } @@ -2264,7 +2332,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 28, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -2279,7 +2347,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 29, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -2294,7 +2362,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 30, + "seq": 34, "time": 0, "data": { "header": { @@ -2337,11 +2405,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 31, + "seq": 35, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 30 + "throughSeq": 34 } } } @@ -2352,7 +2420,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 32, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -2372,7 +2440,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 33, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -2394,7 +2462,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 34, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -2419,7 +2487,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 35, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -2441,7 +2509,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 36, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -2462,7 +2530,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 37, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -2490,11 +2558,11 @@ } }, "sourceEventSeqs": [ - 32, - 33, - 34, - 35, - 36 + 36, + 37, + 38, + 39, + 40 ], "surfaceOp": "append" } @@ -2506,7 +2574,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 38, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2524,7 +2592,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch-start", - "seq": 39, + "seq": 43, "time": 0, "data": { "rootCallId": "advanced-code", @@ -2544,7 +2612,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 40, + "seq": 44, "time": 0, "data": { "rootCallId": "advanced-code", @@ -2571,7 +2639,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 41, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2599,7 +2667,7 @@ } }, "sourceEventSeqs": [ - 38 + 42 ], "surfaceOp": "append" } @@ -2611,7 +2679,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 42, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2626,7 +2694,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 43, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2641,11 +2709,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 44, + "seq": 48, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 43 + "throughSeq": 47 } } } @@ -2656,7 +2724,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 45, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -2676,7 +2744,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 46, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -2698,7 +2766,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 47, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -2723,7 +2791,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -2745,7 +2813,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -2766,7 +2834,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 50, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -2794,11 +2862,11 @@ } }, "sourceEventSeqs": [ - 45, - 46, - 47, - 48, - 49 + 49, + 50, + 51, + 52, + 53 ], "surfaceOp": "append" } @@ -2810,7 +2878,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 51, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -2829,13 +2897,27 @@ "childSessionId": "{{child-1}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "permission/preset", + "seq": 2, + "time": 0, + "data": { + "preset": "danger-full-access" + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -2872,7 +2954,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -2886,7 +2968,7 @@ "sessionId": "{{child-1}}", "event": { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -2903,7 +2985,7 @@ "sessionId": "{{child-1}}", "event": { "type": "subagent/descriptor", - "seq": 3, + "seq": 6, "time": 0, "data": { "version": 2, @@ -2920,7 +3002,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/start", - "seq": 4, + "seq": 7, "time": 0, "data": { "turn": 1, @@ -2935,7 +3017,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 5, + "seq": 8, "time": 0, "data": { "content": [ @@ -2960,13 +3042,13 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 6, + "seq": 9, "time": 0, "data": { "content": [ { "type": "text", - "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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." + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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": { @@ -2974,6 +3056,14 @@ "plugin": "@deepseek-ai/dsh-system-prompt", "form": "snapshot", "sections": [ + { + "name": "sandbox:policy", + "text": "Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations." + }, + { + "name": "approval:policy", + "text": "Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." + }, { "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." @@ -2993,12 +3083,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session/title", - "seq": 7, + "seq": 10, "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ - 5 + 8 ], "source": { "kind": "fallback" @@ -3013,7 +3103,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 8, + "seq": 11, "time": 0, "data": { "header": { @@ -3056,7 +3146,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/context", - "seq": 9, + "seq": 12, "time": 0, "data": { "provider": "deepseek-official", @@ -3072,11 +3162,11 @@ "sessionId": "{{child-1}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 10, + "seq": 13, "time": 0, "data": { "sessionId": "{{child-1}}", - "throughSeq": 9 + "throughSeq": 12 } } } @@ -3087,7 +3177,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -3107,7 +3197,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -3127,7 +3217,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -3150,7 +3240,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -3172,7 +3262,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 15, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -3193,7 +3283,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -3219,11 +3309,11 @@ } }, "sourceEventSeqs": [ - 11, - 12, - 13, 14, - 15 + 15, + 16, + 17, + 18 ], "surfaceOp": "append" } @@ -3235,7 +3325,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -3250,7 +3340,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -3291,7 +3381,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 52, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -3319,7 +3409,7 @@ } }, "sourceEventSeqs": [ - 51 + 55 ], "surfaceOp": "append" } @@ -3331,7 +3421,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 53, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -3346,7 +3436,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 54, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -3361,11 +3451,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 55, + "seq": 59, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 54 + "throughSeq": 58 } } } @@ -3376,7 +3466,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 56, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -3396,7 +3486,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 57, + "seq": 61, "time": 0, "data": { "turn": 1, @@ -3418,7 +3508,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 58, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -3443,7 +3533,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -3465,7 +3555,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -3486,7 +3576,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 61, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -3514,11 +3604,11 @@ } }, "sourceEventSeqs": [ - 56, - 57, - 58, - 59, - 60 + 60, + 61, + 62, + 63, + 64 ], "surfaceOp": "append" } @@ -3530,7 +3620,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 62, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -3548,7 +3638,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-start", - "seq": 63, + "seq": 67, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -3564,13 +3654,27 @@ "childSessionId": "{{child-2}}" } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "permission/preset", + "seq": 2, + "time": 0, + "data": { + "preset": "danger-full-access" + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "agent/inbox/spliced", - "seq": 0, + "seq": 3, "time": 0, "data": { "target": "next-turn", @@ -3607,7 +3711,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/start", - "seq": 1, + "seq": 4, "time": 0, "data": { "turn": 1 @@ -3621,7 +3725,7 @@ "sessionId": "{{child-2}}", "event": { "type": "agent/inbox/spliced", - "seq": 2, + "seq": 5, "time": 0, "data": { "target": "next-turn", @@ -3632,13 +3736,31 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool-workflow/agent-start", + "seq": 68, + "time": 0, + "data": { + "runId": "{{workflow-run}}", + "seq": 1, + "label": "workflow-child", + "phase": "Delegate", + "childId": "{{child-2}}" + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "subagent/descriptor", - "seq": 3, + "seq": 6, "time": 0, "data": { "version": 2, @@ -3654,7 +3776,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/start", - "seq": 4, + "seq": 7, "time": 0, "data": { "turn": 1, @@ -3669,7 +3791,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 5, + "seq": 8, "time": 0, "data": { "content": [ @@ -3694,13 +3816,13 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 6, + "seq": 9, "time": 0, "data": { "content": [ { "type": "text", - "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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." + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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": { @@ -3708,6 +3830,14 @@ "plugin": "@deepseek-ai/dsh-system-prompt", "form": "snapshot", "sections": [ + { + "name": "sandbox:policy", + "text": "Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations." + }, + { + "name": "approval:policy", + "text": "Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." + }, { "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." @@ -3727,12 +3857,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session/title", - "seq": 7, + "seq": 10, "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ - 5 + 8 ], "source": { "kind": "fallback" @@ -3747,7 +3877,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 8, + "seq": 11, "time": 0, "data": { "header": { @@ -3790,7 +3920,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/context", - "seq": 9, + "seq": 12, "time": 0, "data": { "provider": "deepseek-official", @@ -3800,35 +3930,17 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "tool-workflow/agent-start", - "seq": 64, - "time": 0, - "data": { - "runId": "{{workflow-run}}", - "seq": 1, - "label": "workflow-child", - "phase": "Delegate", - "childId": "{{child-2}}" - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 10, + "seq": 13, "time": 0, "data": { "sessionId": "{{child-2}}", - "throughSeq": 9 + "throughSeq": 12 } } } @@ -3839,7 +3951,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -3859,7 +3971,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -3879,7 +3991,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -3902,7 +4014,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -3924,7 +4036,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 15, + "seq": 18, "time": 0, "data": { "turn": 1, @@ -3945,7 +4057,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -3971,11 +4083,11 @@ } }, "sourceEventSeqs": [ - 11, - 12, - 13, 14, - 15 + 15, + 16, + 17, + 18 ], "surfaceOp": "append" } @@ -3987,7 +4099,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -4002,7 +4114,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -4043,7 +4155,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/agent-end", - "seq": 65, + "seq": 69, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -4059,7 +4171,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool-workflow/run-end", - "seq": 66, + "seq": 70, "time": 0, "data": { "runId": "{{workflow-run}}", @@ -4074,7 +4186,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 67, + "seq": 71, "time": 0, "data": { "turn": 1, @@ -4102,7 +4214,7 @@ } }, "sourceEventSeqs": [ - 62 + 66 ], "surfaceOp": "append" } @@ -4114,7 +4226,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 68, + "seq": 72, "time": 0, "data": { "turn": 1, @@ -4129,7 +4241,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 69, + "seq": 73, "time": 0, "data": { "turn": 1, @@ -4144,11 +4256,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 70, + "seq": 74, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 69 + "throughSeq": 73 } } } @@ -4159,7 +4271,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 71, + "seq": 75, "time": 0, "data": { "turn": 1, @@ -4179,7 +4291,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 72, + "seq": 76, "time": 0, "data": { "turn": 1, @@ -4201,7 +4313,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 73, + "seq": 77, "time": 0, "data": { "turn": 1, @@ -4226,7 +4338,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 74, + "seq": 78, "time": 0, "data": { "turn": 1, @@ -4248,7 +4360,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 75, + "seq": 79, "time": 0, "data": { "turn": 1, @@ -4269,7 +4381,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 76, + "seq": 80, "time": 0, "data": { "turn": 1, @@ -4297,11 +4409,11 @@ } }, "sourceEventSeqs": [ - 71, - 72, - 73, - 74, - 75 + 75, + 76, + 77, + 78, + 79 ], "surfaceOp": "append" } @@ -4313,7 +4425,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 77, + "seq": 81, "time": 0, "data": { "turn": 1, @@ -4331,7 +4443,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 78, + "seq": 82, "time": 0, "data": { "turn": 1, @@ -4359,7 +4471,7 @@ } }, "sourceEventSeqs": [ - 77 + 81 ], "surfaceOp": "append" } @@ -4371,7 +4483,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 79, + "seq": 83, "time": 0, "data": { "turn": 1, @@ -4386,7 +4498,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 80, + "seq": 84, "time": 0, "data": { "turn": 1, @@ -4401,7 +4513,7 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 81, + "seq": 85, "time": 0, "data": { "header": { @@ -4443,11 +4555,11 @@ "sessionId": "{{parent}}", "event": { "type": "session-log-deepseek/delivery-accepted", - "seq": 82, + "seq": 86, "time": 0, "data": { "sessionId": "{{parent}}", - "throughSeq": 81 + "throughSeq": 85 } } } @@ -4458,7 +4570,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 83, + "seq": 87, "time": 0, "data": { "turn": 1, @@ -4478,7 +4590,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 84, + "seq": 88, "time": 0, "data": { "turn": 1, @@ -4498,7 +4610,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 85, + "seq": 89, "time": 0, "data": { "turn": 1, @@ -4521,7 +4633,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 86, + "seq": 90, "time": 0, "data": { "turn": 1, @@ -4543,7 +4655,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 87, + "seq": 91, "time": 0, "data": { "turn": 1, @@ -4564,7 +4676,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 88, + "seq": 92, "time": 0, "data": { "turn": 1, @@ -4590,11 +4702,11 @@ } }, "sourceEventSeqs": [ - 83, - 84, - 85, - 86, - 87 + 87, + 88, + 89, + 90, + 91 ], "surfaceOp": "append" } @@ -4606,7 +4718,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 89, + "seq": 93, "time": 0, "data": { "turn": 1, @@ -4621,7 +4733,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 90, + "seq": 94, "time": 0, "data": { "turn": 1, @@ -4639,6 +4751,5 @@ "status": "idle" } } - ], - "session_root": "{{cwd}}/sessions" + ] } diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 6e2f62bfe1..db7a626752 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,20 +1,23 @@ {"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","throughSeq":9}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index e11cec5548..eed0d049f5 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,20 +1,23 @@ {"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} +{"type":"approval/policy","data":{"policy":"never","source":"delegation"}} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","throughSeq":9}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 2e65037b37..a361560a35 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,92 +1,96 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"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":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":7}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":11}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":18}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":22}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-run","name":"cordis_run","argumentsDelta":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[30],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"change"}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":30}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":34}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[42],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":43}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":47}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":54}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":58}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} {"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}} {"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}} {"type":"tool-workflow/agent-end","data":{"runId":"{{workflow-run}}","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"{{workflow-run}}","stopReason":"completed"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":69}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":73}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-undefine","name":"cordis_undefine","argumentsDelta":"{\"pluginId\": \"snap-1\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[71,72,73,74,75],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[81],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"change"}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":81}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","throughSeq":85}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[83,84,85,86,87],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[87,88,89,90,91],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/restart/requests.json b/scripts/snapshots/python-sdk-single-exe/restart/requests.json index dd5f925294..a7efe0f6ce 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/requests.json +++ b/scripts/snapshots/python-sdk-single-exe/restart/requests.json @@ -9,6 +9,10 @@ { "role": "user", "content": "Complete the first isolated Python SDK process turn." + }, + { + "role": "user", + "content": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." } ], "toolNames": [ @@ -37,6 +41,10 @@ { "role": "user", "content": "Complete the second isolated Python SDK process turn." + }, + { + "role": "user", + "content": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)." } ], "toolNames": [ diff --git a/scripts/snapshots/python-sdk-single-exe/restart/result.json b/scripts/snapshots/python-sdk-single-exe/restart/result.json index 32911ef466..d1aac3e127 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/result.json +++ b/scripts/snapshots/python-sdk-single-exe/restart/result.json @@ -9,51 +9,6 @@ "agent/inbox/spliced", "step/start", "user/message", - "session/title", - "request/header", - "request/context", - "session-log-deepseek/delivery-accepted", - "assistant/chunk", - "assistant/chunk", - "assistant/chunk", - "assistant/chunk", - "assistant/chunk", - "assistant/message", - "step/end", - "turn/end" - ], - "notificationMethods": [ - "session.event", - "session.status", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.event", - "session.status" - ], - "session_root": "{{sessions}}" - }, - { - "session_id": "{{session-2}}", - "final_response": "PROCESS_TWO_OK", - "finish_reason": "completed", - "eventTypes": [ - "agent/inbox/spliced", - "turn/start", - "agent/inbox/spliced", - "step/start", "user/message", "session/title", "request/header", @@ -87,8 +42,55 @@ "session.event", "session.event", "session.event", + "session.event", "session.status" + ] + }, + { + "session_id": "{{session-2}}", + "final_response": "PROCESS_TWO_OK", + "finish_reason": "completed", + "eventTypes": [ + "agent/inbox/spliced", + "turn/start", + "agent/inbox/spliced", + "step/start", + "user/message", + "user/message", + "session/title", + "request/header", + "request/context", + "session-log-deepseek/delivery-accepted", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/chunk", + "assistant/message", + "step/end", + "turn/end" ], - "session_root": "{{sessions}}" + "notificationMethods": [ + "session.event", + "session.status", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.event", + "session.status" + ] } ] diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl index 38e5f98ff6..babaec2193 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl @@ -1,18 +1,22 @@ {"type":"session","version":0,"id":"{{session-1}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"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":"user/message","data":{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Complete the first isolated Python","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Complete the first isolated Python","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-1}}","throughSeq":7}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-1}}","throughSeq":11}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_ONE_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_ONE_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl index 523f784dfa..afd9753b4a 100644 --- a/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl @@ -1,18 +1,22 @@ {"type":"session","version":0,"id":"{{session-2}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"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":"user/message","data":{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Complete the second isolated Python","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Complete the second isolated Python","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-2}}","throughSeq":7}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-2}}","throughSeq":11}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_TWO_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_TWO_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From f0f9b294ddee7a0ced10bb19aa2c8af4b91c2a35 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:56:55 +0800 Subject: [PATCH 25/42] docs(python): make the dsh profile runtime current Document dsh as the only application launcher across architecture, CLI, SDK, app-boot, Python package, contributor, tutorial, and example references. Explain explicit home selection, profile and patch precedence, persistent external plugin installation, the Node-free runtime path, and the absence of complete-config or ~/.dsh fallbacks. Record the Python profile-runtime decision and update the active naming, installed-wheel, and SEA packaging notes with precise supersession. Regenerate the configuration catalog and module graph after deleting the carrier, update both reviewed languages and pairing records, and classify the retained standalone Cordis files as lower-level test fixtures rather than launch interfaces. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 8 +- ...-executable-sdk-runtime-distribution.zh.md | 8 +- ...aming-contract-and-rename-ledger.i18n.yaml | 4 +- ...itory-naming-contract-and-rename-ledger.md | 8 +- ...ry-naming-contract-and-rename-ledger.zh.md | 8 +- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 6 ++ ...26-08-23-python-sdk-dsh-profile-runtime.md | 53 ++++++++++++ ...08-23-python-sdk-dsh-profile-runtime.zh.md | 53 ++++++++++++ ...talled-python-wheel-black-box-ci.i18n.yaml | 4 +- ...-23-installed-python-wheel-black-box-ci.md | 4 +- ...-installed-python-wheel-black-box-ci.zh.md | 4 +- AGENTS.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 - docs/config-catalog.zh.md | 1 - docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 - docs/module-graph.zh.md | 3 - docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 66 ++++++++------- docs/user/guide/python-sdk.zh.md | 84 ++++++++++--------- examples/python-sdk-agent/README.i18n.yaml | 4 +- examples/python-sdk-agent/README.md | 54 ++++++------ examples/python-sdk-agent/README.zh.md | 52 ++++++------ examples/python-sdk-agent/cordis.snapshot.yml | 7 +- examples/python-sdk-agent/cordis.yml | 5 +- examples/python-sdk-agent/minimal.cordis.yml | 7 +- packages/README.i18n.yaml | 4 +- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 4 +- packages/boot/app-boot/README.zh.md | 4 +- packages/sdk/README.i18n.yaml | 4 +- packages/sdk/README.md | 3 +- packages/sdk/README.zh.md | 3 +- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 8 +- packages/sdk/server/README.zh.md | 8 +- python/README.i18n.yaml | 4 +- python/README.md | 4 +- python/README.zh.md | 4 +- python/development.i18n.yaml | 4 +- python/development.md | 14 ++-- python/development.zh.md | 14 ++-- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 41 +++++---- python/sdk-runtime/README.zh.md | 41 +++++---- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 67 +++++++++------ python/sdk/README.zh.md | 64 ++++++++------ 61 files changed, 480 insertions(+), 324 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index be7aedb4bd..29b7caeb50 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: e5438fc01fdd634cd4d8535dfa37a1de356da04d -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 218133f077bed7584b6a26fea5f939b64d4fd670 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 50e63bbc4f664f9450f01acf45888991a27517d2 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cf92f42f508f005c64f09aa0127b28ed162cede5 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index e5438fc01f..50e63bbc4f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,14 +23,14 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. -### The serving interface is a plugin: the two packages sdk/server + sdk/python-runtime +### The serving interface is a plugin inside the dsh application -The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: +The deterministic serving surface is a plugin selected by the packaged `dsh` application: - [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.md) (`@deepseek-ai/dsh-sdk-python-runtime`): a private packaged entry — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the packaged entry (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). +- [`apps/cli`](../../../../apps/cli/README.md) (`@deepseek-ai/dsh`): the packaged application entry; its `sdk` profile mounts `dsh-sdk-jsonrpc-server`, and the CLI owns environment layering, profile composition, stdin/signal shutdown, and process exit. -Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. +The Python client supplies an explicit Harness home and selects the `sdk` profile plus ordered patch files. A missing home, profile, bundle, or server row fails loudly; there is no external complete-config fallback. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this application surface. ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 218133f077..cf92f42f50 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,14 +23,14 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 -### 对外服务接口也是插件:sdk/server + sdk/python-runtime 两个包 +### 对外服务接口是 dsh 应用中的插件 -确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: +确定性服务接口由打包后的 `dsh` 应用选择为插件: - [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.zh.md)(`@deepseek-ai/dsh-sdk-python-runtime`):私有打包入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归打包入口所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`apps/cli`](../../../../apps/cli/README.zh.md)(`@deepseek-ai/dsh`):打包后的应用入口;其 `sdk` profile 挂载 `dsh-sdk-jsonrpc-server`,CLI 负责环境分层、profile 组合、stdin/signal 关闭与进程退出。 -配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 +Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 patch 文件。缺失 home、profile、bundle 或 server 配置项都会明确失败;不存在外部完整配置回退。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该应用接口。 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml index 6f7a67dd4d..d3d02c9f70 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.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/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md -2026-08-11-repository-naming-contract-and-rename-ledger.md: 2de88df5f1a5037d32daa9a8ee9cd1edfc60528a -2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 0cbbbee561358614da0b20e49c610fbee1d4e537 +2026-08-11-repository-naming-contract-and-rename-ledger.md: 403a7288bb3af0eff09b229e7d399b94492ae6c0 +2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 644e1c0bebfc43414e9e67def8d6c42acddf4c72 diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md index 2de88df5f1..403a7288bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md @@ -274,11 +274,11 @@ Keep MCP, Todo, and the Plan Mode package, key, events, and tool names. This dec | `E2BSandboxService` | `E2BRuntime` | The class creates, reuses, and disposes the E2B execution environment used by filesystem and subprocess adapters. It is broader than one sandbox handle and narrower than a generic owner. Keep `@deepseek-ai/dsh-e2b`, `ctx.e2b`, and the `e2b/` group. | | `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | The package is the Host plugin that serves the frontend assets. The prefix distinguishes it from frontend application code. | | `PluginInventoryService` | `PluginInventoryGateway` | The class is a Remote-only adapter from the live Loader tree to the `pluginInventory/list` RPC. It owns no same-process service, cache, history, or mutation path. `Gateway` states the role that exists. | -| `@deepseek-ai/dsh-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | The private package carries only the temporarily separate Python SDK runtime; the [single dsh launcher decision](2026-08-22-single-dsh-application-launcher.md) owns its application-boundary change. | -| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | The carrier is production packaging infrastructure for the Python SDK, not a demo bundle. | -| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | The direct-config runnable example belongs specifically to the Python SDK exception. | +| `@deepseek-ai/dsh-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-python-runtime` | removed | The Python runtime packages the existing `@deepseek-ai/dsh` CLI and its `sdk` profile; a private application package would recreate a second launcher. | +| `packages/examples/jsonrpc-demo/`, `packages/sdk/python-runtime/` | removed | The Python runtime wheel's closure manifest owns packaging without a separate application package. | +| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | The example demonstrates Python use of the `sdk` profile and ordered patches. | | `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | The package is the ACP profile's application bundle, not a standalone demo bin. | -| Deploy-root manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | The manifest defines the private Python runtime dependency closure. The Python-visible executable basename remains fixed until its documented profile migration. | +| Deploy-root manifests `dsh-jsonrpc-agent-pkg`, `dsh-sdk-python-runtime-closure` | `dsh-python-runtime-closure` | The zero-code manifest defines the Python runtime wheel's complete `dsh` dependency closure without naming a separate SDK application. | | `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | The application is the web frontend. Keep its physical `apps/web/` folder. | Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, `dsh-sdk-app`, `dsh-acp-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution. diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md index 0cbbbee561..644e1c0beb 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md @@ -274,11 +274,11 @@ PascalCase 标识符中的首字母缩略词使用首字母大写格式:`Ui` | `E2BSandboxService` | `E2BRuntime` | 该类创建、复用和释放文件系统与子进程适配器所使用的 E2B 执行环境。它比单个沙箱句柄的职责更广,又比通用所有者更具体。保留 `@deepseek-ai/dsh-e2b`、`ctx.e2b` 和 `e2b/` 组。 | | `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | 该包是提供前端资源的 Host 插件。此前缀可将它与前端应用代码区分开。 | | `PluginInventoryService` | `PluginInventoryGateway` | 该类只负责把实时 Loader 树适配到 `pluginInventory/list` RPC。它不拥有同进程服务、缓存、历史或修改路径。`Gateway` 准确说明现有角色。 | -| `@deepseek-ai/dsh-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | 该私有包只承载暂时独立的 Python SDK 运行时;其应用边界变更由[单一 dsh 启动器决策](2026-08-22-single-dsh-application-launcher.zh.md)负责。 | -| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | 该载体是 Python SDK 的生产打包基础设施,不是演示组合包。 | -| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | 直读配置的可运行示例专属于 Python SDK 例外。 | +| `@deepseek-ai/dsh-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-python-runtime` | 已删除 | Python 运行时打包现有 `@deepseek-ai/dsh` CLI 与其 `sdk` profile;私有应用包会重新产生第二个启动器。 | +| `packages/examples/jsonrpc-demo/`、`packages/sdk/python-runtime/` | 已删除 | Python 运行时 wheel 的闭包 manifest 负责打包,无需独立应用包。 | +| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | 该示例演示 Python 使用 `sdk` profile 与有序 patch。 | | `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | 该包是 ACP profile 的应用组合包,不是独立 demo bin。 | -| 部署根 manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | 该 manifest 定义私有 Python 运行时依赖闭包。面向 Python 的可执行文件基本名称保持不变,直至完成已记录的 profile 迁移。 | +| 部署根 manifest `dsh-jsonrpc-agent-pkg`、`dsh-sdk-python-runtime-closure` | `dsh-python-runtime-closure` | 该零代码 manifest 定义 Python 运行时 wheel 的完整 `dsh` 依赖闭包,不再命名独立 SDK 应用。 | | `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | 该应用是 Web 前端。保留其物理目录 `apps/web/`。 | 保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、`dsh-sdk-app`、`dsh-acp-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml new file mode 100644 index 0000000000..2e98550ed0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +2026-08-23-python-sdk-dsh-profile-runtime.md: 400915155460598e754c2f81d60b26e693ff438a +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: a3c9074817d1c1e9b3f59fbe5fb02910d20e02f4 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md new file mode 100644 index 0000000000..4009151554 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -0,0 +1,53 @@ +# Agent Note: Python SDK runtime through the dsh profile launcher + +Status: implemented + +English | [中文](2026-08-23-python-sdk-dsh-profile-runtime.zh.md) + +## Problem + +The Python SDK distributed a private Node application that booted a complete external `cordis.yml`, while every other supported application entered through `dsh` profiles. That exception duplicated environment loading, configuration ownership, plugin resolution, shutdown, artifact names, and test paths. It also made SDK customization an all-or-nothing application tree: a caller replacing one plugin had to own the JSON-RPC server and every unrelated deployment row. + +A normal profile cannot be adopted only at the Python wrapper. The runtime executable must contain the `dsh` CLI, shipped profile and bundle files, native libraries, and a module-resolution path that works when profile files and external plugins live outside pkg's virtual filesystem. + +## Decision + +### One application launcher + +The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. + +The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, and timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. + +Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME` in the child environment. The SDK never discovers `~/.dsh`. The selected home consistently owns profiles, external plugins, credentials, settings, and sessions. + +### Plugin customization + +Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback. + +The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. + +### Executable packaging + +The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. + +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy mirrors the source package's explicit runtime exports and identity and re-exports its virtual module URLs. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. + +The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. + +## Existing decisions and supersession + +This decision implements and supersedes the Python exception and deferred-migration sections of [the single dsh application launcher](2026-08-22-single-dsh-application-launcher.md). It supersedes the private application, external complete-config, artifact-name, and customization facts in [the single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md), which remains authoritative for pkg/SEA, wheel construction, native target validation, and publication. No active note is fully superseded, so none is archived. + +## Alternatives considered + +**Keep complete `cordis.yml` as an advanced escape hatch.** Rejected because it preserves a second application assembly and lets a caller bypass profile environment, plugin, and shutdown ownership. + +**Silently use `~/.dsh` for compatibility.** Rejected because an SDK process must not inherit a person's plugins, credentials, settings, or sessions without an explicit choice. + +**Copy the virtual dependency tree into every home.** Rejected because it duplicates hundreds of megabytes and loads a second Cordis instance. Export-preserving proxies are small and retain module identity. + +**Bundle pnpm and Node package management into every SDK launch.** Rejected because installed plugins are deployment state, not per-turn runtime work. Only `dsh plugin` needs the external package manager. + +## Consequences + +Python callers configure the same profile vocabulary as TypeScript and direct CLI users, and arbitrary external bundles can extend an SDK profile without replacing the application tree. Homes must now be selected explicitly, complete-config and `session_root` parameters are unavailable, and the executable includes shared-library assets plus profile-module proxies. The stronger installed-wheel CI makes those package, profile, native, and provider paths release requirements rather than source-only assumptions. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md new file mode 100644 index 0000000000..a3c9074817 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 通过 dsh profile 启动器运行 Python SDK 运行时 + +Status: implemented + +[English](2026-08-23-python-sdk-dsh-profile-runtime.md) | 中文 + +## 问题 + +Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml`;其他所有受支持应用都从 `dsh` profile 进入。该例外重复了环境加载、配置所有权、插件解析、关闭流程、产物命名与测试路径。它还把 SDK 自定义变成全量替换应用树:只想替换一个插件的调用方也必须拥有 JSON-RPC server 和所有无关 deployment 配置项。 + +仅修改 Python wrapper 无法采用普通 profile。运行时可执行程序必须包含 `dsh` CLI、随附 profile 与 bundle 文件、原生库,以及在 profile 文件和外部插件位于 pkg 虚拟文件系统之外时仍可工作的模块解析路径。 + +## 决策 + +### 一个应用启动器 + +运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 + +公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择与 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 + +每次 Python 启动都要求显式 `dsh_home`,或子进程环境中的非空 `DSH_HOME`。SDK 绝不会发现 `~/.dsh`。所选 home 统一拥有 profile、外部插件、凭据、设置与会话。 + +### 插件自定义 + +持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退。 + +运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 + +### 可执行程序打包 + +零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 + +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理镜像源包的显式运行时 exports 与身份,并重新导出其虚拟模块 URL。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 + +已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 + +## 既有决策与取代关系 + +本决策实现并取代[单一 dsh 应用启动器](2026-08-22-single-dsh-application-launcher.zh.md)中的 Python 例外与延后迁移章节。它取代[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的私有应用、外部完整配置、产物名称与自定义事实;后者继续负责 pkg/SEA、wheel 构建、原生目标验证与发布。没有任何 active note 被完全取代,因此无需归档。 + +## 考虑过的替代方案 + +**把完整 `cordis.yml` 保留为高级逃生口。** 不予采用,因为它会保留第二套应用组装,并允许调用方绕过 profile 的环境、插件与关闭所有权。 + +**为兼容性静默使用 `~/.dsh`。** 不予采用,因为 SDK 进程不应在没有显式选择时继承个人插件、凭据、设置或会话。 + +**把虚拟依赖树复制到每个 home。** 不予采用,因为这会重复数百 MB,并加载第二个 Cordis 实例。保留 exports 的代理很小,而且维持模块身份。 + +**把 pnpm 与 Node 包管理纳入每次 SDK 启动。** 不予采用,因为已安装插件属于 deployment 状态,而不是逐轮运行时工作。只有 `dsh plugin` 需要外部包管理器。 + +## 结果 + +Python 调用方使用与 TypeScript 和直接 CLI 用户相同的 profile 词汇,任意外部 bundle 可以扩展 SDK profile,而无需替换应用树。调用方现在必须显式选择 home,完整配置与 `session_root` 参数不可用,可执行程序则包含共享库资源和 profile 模块代理。更强的 installed-wheel CI 将包、profile、原生与提供方路径变成发布要求,而不是仅在源码中成立的假设。 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml index 93a20e387f..635b5df7af 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.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/testing/2026-08-23-installed-python-wheel-black-box-ci.md -2026-08-23-installed-python-wheel-black-box-ci.md: f2b5bd0edeb02a5d72e8010c62c3cfb59ee95c5d -2026-08-23-installed-python-wheel-black-box-ci.zh.md: fb0f5fb2da676f1a24a2630bd45f005f46a3095e +2026-08-23-installed-python-wheel-black-box-ci.md: a2fd4134d7bff0e74aa2d1afc3590e9cdd90809e +2026-08-23-installed-python-wheel-black-box-ci.zh.md: 203440ed0f6bbe84257474dd69400a64bc480cd3 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md index f2b5bd0ede..a2fd4134d7 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -18,7 +18,7 @@ The black-box harness rejects a non-venv process, repository-relative working di ### Keyless behavior -Every target runs the complete packaged-runtime scenario set after installation. A local SSE model keeps outputs deterministic while the public SDK exercises the default configuration, an external complete configuration, persistent PTY and editor behavior, worker-thread code and workflow execution, ripgrep-backed search, external stdio MCP discovery and execution, model-visible and durable snapshots, JSONL/Zstandard persistence, direct JSON-RPC, and shutdown. A restart snapshot launches two complete SDK runtime processes against one persistence root and pins their isolated model histories, high-level results, and separate durable logs. The installed run replaces the source-SDK pre-wheel run; the executable and wheel are tested together once rather than maintaining two behavior inventories. +Every target runs the complete packaged-runtime scenario set after installation. A local SSE model keeps outputs deterministic while the public SDK exercises the default SDK profile, ordered patch overlays, external bundle installation through `dsh plugin`, persistent PTY and editor behavior, worker-thread code and workflow execution, ripgrep-backed search, external stdio MCP discovery and execution, model-visible and durable snapshots, Zstandard persistence, direct JSON-RPC, and shutdown. A restart snapshot launches two complete SDK runtime processes against one persistence root and pins their isolated model histories, high-level results, and separate durable logs. The installed run replaces the source-SDK pre-wheel run; the executable and wheel are tested together once rather than maintaining two behavior inventories. Linux additionally retains its manylinux 2.28 clean-install smoke and GLIBC checks. macOS retains deployment-target and native helper checks. These platform constraints supplement the common black-box behavior rather than substituting for it. @@ -34,7 +34,7 @@ The pull-request `python-runtime` job calls the reusable builder for Linux x64, ## Existing decisions and supersession -This decision supersedes the single-target topology in the archived [required Python runtime pull-request validation](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md) while retaining its requirement that the real executable, snapshots, wheels, and clean installation meet before merge. The [single-file Python SDK runtime distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) remains authoritative for SEA packaging, the closed dependency set, native sidecars, wheel tags, and release artifacts. +This decision supersedes the single-target topology in the archived [required Python runtime pull-request validation](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md) while retaining its requirement that the real executable, snapshots, wheels, and clean installation meet before merge. The [Python SDK dsh profile runtime](../architecture/2026-08-23-python-sdk-dsh-profile-runtime.md) owns the launched application and customization surface; the [single-file Python SDK runtime distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) remains authoritative for SEA packaging, native sidecars, wheel tags, and release artifacts. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md index fb0f5fb2da..203440ed0f 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -18,7 +18,7 @@ Python SDK 单元测试驱动 fake peer,而打包运行时工作流可以在 ### Keyless 行为 -每个目标都会在安装后运行完整的打包运行时场景。一个本地 SSE mock 模型提供确定性输出,公开 SDK 则覆盖默认配置、外部完整配置、持久 PTY 与 editor 行为、worker thread 代码与 workflow 执行、基于 ripgrep 的搜索、外部 stdio MCP 发现与执行、模型可见及持久化快照、JSONL/Zstandard 持久化、直接 JSON-RPC 与关闭。Restart 快照针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。安装后运行取代 wheel 构建前的源码 SDK 运行,因此可执行文件与 wheel 包共同接受一次验证,而不是维护两套行为清单。 +每个目标都会在安装后运行完整的打包运行时场景。一个本地 SSE mock 模型提供确定性输出,公开 SDK 则覆盖默认 SDK profile、有序 patch overlay、通过 `dsh plugin` 安装外部 bundle、持久 PTY 与 editor 行为、worker thread 代码与 workflow 执行、基于 ripgrep 的搜索、外部 stdio MCP 发现与执行、模型可见及持久化快照、Zstandard 持久化、直接 JSON-RPC 与关闭。Restart 快照针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。安装后运行取代 wheel 构建前的源码 SDK 运行,因此可执行文件与 wheel 包共同接受一次验证,而不是维护两套行为清单。 Linux 另外保留 manylinux 2.28 干净安装冒烟测试与 GLIBC 检查。macOS 保留部署目标与原生 helper 检查。这些平台约束补充共同黑盒行为,不能替代它。 @@ -34,7 +34,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Existing decisions and supersession -本决策取代已归档的[必需 Python 运行时拉取请求验证](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md)中的单目标拓扑,同时保留真实可执行文件、快照、wheel 包与干净安装必须在合并前相遇的要求。[单文件 Python SDK 运行时 distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)仍负责 SEA 打包、封闭依赖集合、原生 sidecar、wheel 包标签与发布产物。 +本决策取代已归档的[必需 Python 运行时拉取请求验证](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md)中的单目标拓扑,同时保留真实可执行文件、快照、wheel 包与干净安装必须在合并前相遇的要求。[Python SDK dsh profile 运行时](../architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责启动应用与自定义接口;[单文件 Python SDK 运行时 distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)继续负责 SEA 打包、原生 sidecar、wheel 包标签与发布产物。 ## Alternatives considered diff --git a/AGENTS.md b/AGENTS.md index 3fa1ce77e4..1fd35bb6c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// acp/ automation-only Agent Client Protocol server interaction/ approval/interaction capabilities, permission, commands, ask-user boot/ shared profile/application boot glue - sdk/ JSON-RPC protocol, server, TypeScript client, and private Python carrier + sdk/ JSON-RPC protocol, server, and TypeScript client examples/ reusable demo bundles (agent-spine) experimental/ private prototypes excluded from official releases support/ dev/test infrastructure diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 977871533e..c23418172b 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: d59b8264093dafb0160893c942371a4daa942c14 -README.zh.md: 6a209f5ad64b38c138ae1712a05ed9e840d9a74f +README.md: b64861ce68b4c6de8edc3142fb95d83f1d395d15 +README.zh.md: a42a1b0539703dc03adf0ac53e8b5819940429a2 diff --git a/apps/cli/README.md b/apps/cli/README.md index d59b826409..b64861ce68 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. +The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command and launches its `sdk` profile. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. ## Entry modes diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 6a209f5ad6..a42a1b0539 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。Python 运行时 wheel 会打包同一个命令,并启动其 `sdk` profile。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 ## 入口模式 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 5ed7b32789..f432cc953b 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 0f407afa3b06d144681550d5096bf96c498e6451 -README.zh.md: bf4dc4ca9f49c1801d108234411123de459c0444 +README.md: 2692731ecdfb4a3f798ca0506954289f2c9bdca8 +README.zh.md: 0a2c0a14151f7b774ed00aab808c7313716c131a diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0f407afa3b..2692731ecd 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,7 +8,7 @@ This reference defines the profile, web-alias, plugin-management, and config-dum `dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index bf4dc4ca9f..0a2c0a1415 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -8,7 +8,7 @@ `dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。 `web`、`headless`、`sdk` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 7f2bc49362..fe84ec6adf 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: e37f2321377242803fb89f0a2258682e6c52801c -architecture.zh.md: 69abbcdf52654ecbce6549d25ee089b937228123 +architecture.md: 362a3758fabdb67ea883bcf84ea5df4cf4e77b0f +architecture.zh.md: 421c38ad5b90db7b7ac1b5fe6bb10ccc0793feb5 diff --git a/docs/architecture.md b/docs/architecture.md index e37f232137..362a3758fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Every supported Node application starts at the `dsh` CLI with a named profile. T Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. -The packaged Python SDK runtime is the sole temporary application exception. Its private [`dsh-sdk-python-runtime`](../packages/sdk/python-runtime/README.md) carrier and `dsh-sdk-python-runtime-closure` deploy manifest preserve the current Python API, wire, default `cordis.yml`, environment variables, wheel names, `dsh-jsonrpc-agent-pkg--` executables, sidecars, and platform set. A later Python migration will launch `dsh --profile sdk`, delete the private direct-config carrier, and then rename that executable family to `deepseek-harness-sdk-runtime--`. +The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. ## Core packages diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 69abbcdf52..421c38ad5b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -44,7 +44,7 @@ dsh --profile web --dump-config Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 -打包后的 Python SDK 运行时是唯一的临时应用例外。其私有 [`dsh-sdk-python-runtime`](../packages/sdk/python-runtime/README.zh.md) 载体与 `dsh-sdk-python-runtime-closure` 部署 manifest 保持当前 Python API、协议格式、默认 `cordis.yml`、环境变量、wheel 包名称、`dsh-jsonrpc-agent-pkg--` 可执行文件、伴随文件及平台集合不变。后续 Python 迁移会改为启动 `dsh --profile sdk`、删除私有直读配置载体,然后把该可执行文件族重命名为 `deepseek-harness-sdk-runtime--`。 +Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端以显式 Harness home 启动 `dsh --profile sdk`。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 ## 核心包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index de34a153cf..9dc39c909c 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: 46228907ea4241e69a4439c2dc1c1786de94eaff -config-catalog.zh.md: ed09a3d44f93815ab667ef4613249a33306d9899 +config-catalog.md: acd32b7e76180f13956ec8a54ca16d0ef99bf203 +config-catalog.zh.md: eacca1575191cff6dde93cf80e5ac576e47f485c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 46228907ea..acd32b7e76 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3389,7 +3389,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol` ([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) -- `@deepseek-ai/dsh-sdk-python-runtime` ([`packages/sdk/python-runtime/src/index.ts`](../packages/sdk/python-runtime/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-in-process-driver` ([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ed09a3d44f..eacca15751 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3390,7 +3390,6 @@ export interface Config { - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol`([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) -- `@deepseek-ai/dsh-sdk-python-runtime`([`packages/sdk/python-runtime/src/index.ts`](../packages/sdk/python-runtime/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-in-process-driver`([`packages/subagent/subagent-in-process-driver/src/index.ts`](../packages/subagent/subagent-in-process-driver/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8ba88343ec..e5f37e4a8c 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: aeb35195f3a095b9de694f164dc1111acf5c8197 -module-graph.zh.md: ef3bc3fbce9cad37542eeea6adf936dd70e6581b +module-graph.md: d401df81c59de27a0171750d92031c40939c6564 +module-graph.zh.md: 8186217711a2ca320017dc0473227f7a9cf59b1a diff --git a/docs/module-graph.md b/docs/module-graph.md index aeb35195f3..d401df81c5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -275,7 +275,6 @@ flowchart TD pkg_sdk_client["sdk-client"] pkg_sdk_jsonrpc_server["sdk-jsonrpc-server"] pkg_sdk_protocol["sdk-protocol"] - pkg_sdk_python_runtime["sdk-python-runtime"] end subgraph group_session["packages/session"] pkg_session_checkpoint_policy["session-checkpoint-policy"] @@ -381,7 +380,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_sandbox_windows_acl --> pkg_invariants - pkg_sdk_python_runtime --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_win32_process --> pkg_invariants @@ -1672,7 +1670,6 @@ flowchart TD | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`sdk-python-runtime`](../packages/sdk/python-runtime) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ef3bc3fbce..8186217711 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -277,7 +277,6 @@ flowchart TD pkg_sdk_client["sdk-client"] pkg_sdk_jsonrpc_server["sdk-jsonrpc-server"] pkg_sdk_protocol["sdk-protocol"] - pkg_sdk_python_runtime["sdk-python-runtime"] end subgraph group_session["packages/session"] pkg_session_checkpoint_policy["session-checkpoint-policy"] @@ -383,7 +382,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_sandbox_windows_acl --> pkg_invariants - pkg_sdk_python_runtime --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_win32_process --> pkg_invariants @@ -1674,7 +1672,6 @@ flowchart TD | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`sdk-python-runtime`](../packages/sdk/python-runtime) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index f2299b6512..bbb6cff7ec 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md -python-sdk.md: 21c7a908a8524b16baf8f98453746f59b5d5efc8 -python-sdk.zh.md: bb883f6f3799225bda590a80883063ecb29e15b4 +python-sdk.md: 4fbc6b343714b232b90a7954b35bf6863716281b +python-sdk.zh.md: 51637ed986d298d46633891077bf2a9d27c35bdd diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 21c7a908a8..4fbc6b3437 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -2,7 +2,7 @@ English | [中文](python-sdk.zh.md) -This tutorial is the programmatic alternative to the Web UI. It installs the published Python SDK, runs a checked-in agent composition, and shows how to call the same API from your own program. +This tutorial installs the published Python SDK, runs the checked-in minimal profile overlay, and shows how to customize the same `dsh` profile from your own program. ## Prerequisites @@ -10,12 +10,10 @@ This tutorial is the programmatic alternative to the Web UI. It installs the pub - Git - Linux x64, Linux arm64, or macOS 14 or newer on arm64 - A DeepSeek-compatible API endpoint and credential -- An isolated workspace that the agent may modify +- An isolated workspace and an isolated Harness home ## Install the SDK -Clone the repository for its runnable example, create a virtual environment, and install the SDK with its same-version bundled runtime: - ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -24,51 +22,48 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` -The installed runtime needs no system Node.js. Repository contributors who need to build the runtime or wheels from source should use the [Python contributor workflows](../../../python/development.md). +The installation includes a matching native runtime wheel and the `dsh` command. Normal SDK execution needs no system Node.js. Repository contributors who build the artifacts should use the [Python contributor workflow](../../../python/development.md). ## Run the checked-in example -Set the credential in the environment. Set `DEEPSEEK_BASE_URL` as well when the model is served by an OpenAI-compatible proxy rather than the default DeepSeek endpoint. +Export the credential and, when needed, a compatible proxy endpoint: ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 -# export DSH_MODEL=deepseek-v4-flash -# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.' ``` -Run one task against an isolated workspace and session directory: +Run one task with explicit workspace and home paths: ```sh python examples/python-sdk-agent/minimal.py \ - --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/sessions \ + --workspace /absolute/path/to/disposable-workspace \ + --dsh-home /absolute/path/to/example-dsh-home \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -The script prints the final assistant response. The session directory receives a JSONL log containing the assembled model requests and tool calls. +The script prints the final assistant response. The selected home receives the generated `sdk` profile, settings, credentials if you add them, installed plugins, and Zstandard session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. -## Use the SDK in your own program - -The checked-in example is a thin wrapper around this SDK call: +## Use the SDK in your program ```python from pathlib import Path from deepseek_harness import DeepSeekHarness -config = Path("examples/python-sdk-agent/minimal.cordis.yml").resolve() -workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/sessions").resolve() +workspace = Path("/absolute/path/to/disposable-workspace").resolve() +dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() +patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), - session_root=str(sessions), - cordis=str(config), + dsh_home=str(dsh_home), + profile="sdk", + patches=(str(patch),), ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -78,9 +73,23 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` starts the bundled runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same durable conversation. +The SDK starts the bundled `dsh --profile sdk` process lazily and reuses it until context-manager exit. The profile, its persistent patch, the home patch, and the ordered `patches` tuple form the application configuration. There is no separate Python runtime bin or complete-config option. -## Understand the example composition +## Install or define plugins + +Use `dsh plugin` for dependencies and bundle layers that should persist in this home: + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +The first command initializes the shipped SDK profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. + +Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. + +## Understand the minimal overlay | Property | Value | |---|---| @@ -90,15 +99,10 @@ print(result.final_response) | Bash timeout | 300 seconds | | Editor output limit | 16,000 characters | | Context compaction | Disabled | -| Filesystem | Bare local backend; absolute editor paths may address any path visible to the runtime process | -| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` | +| Session persistence | Zstandard JSONL under `/sessions` | -The composition omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, compaction, and every other model-facing plugin. Sandbox-policy facts are logged as runtime user context rather than appended to the system prompt. +The overlay removes runtime-context prompt messages and most default tools while retaining the SDK application's protocol and persistence. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. -## Choose workspace and session IDs +Use a fresh home when profiles, plugins, credentials, settings, and sessions must be isolated. Use a fresh session id for independent work; reuse a harness, home, and id only to continue the same durable conversation and session-owned resources. -`cwd` selects the workspace available to the agent, while `session_root` stores session logs and state. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same conversation and persistent shell state. - -The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate, so this composition does not support Windows agents. - -The [`python-sdk-agent` example reference](../../../examples/python-sdk-agent/README.md) owns the exact composition. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, runtime selection, and configuration; the [Cordis primer](../../cordis-primer.md) covers composition syntax. +The [example reference](../../../examples/python-sdk-agent/README.md) owns the checked-in overlay. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, and low-level behavior; the [dsh CLI reference](../../../apps/cli/reference/README.md) covers profile layering. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index bb883f6f37..51637ed986 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -1,21 +1,19 @@ -# Python SDK 快速上手 +# Python SDK 入门 [English](python-sdk.md) | 中文 -本教程介绍 Web UI 之外的程序化使用方式:安装已发布的 Python SDK、运行仓库内置的 agent(智能体)组合,并在自己的程序中调用同一套 API。 +本教程安装已发布的 Python SDK,运行检入的极简 profile overlay,并说明如何从自己的程序自定义同一个 `dsh` profile。 -## 前置要求 +## 前置条件 - Python 3.10 或更高版本 - Git -- Linux x64、Linux arm64 或 macOS 14 或更高版本的 arm64 -- DeepSeek 兼容的 API 端点与凭据 -- agent 可以修改的隔离 workspace +- Linux x64、Linux arm64,或 arm64 上的 macOS 14 或更高版本 +- DeepSeek 兼容的 API endpoint 与凭据 +- 隔离的 workspace 与隔离的 Harness home ## 安装 SDK -克隆仓库以使用其中的可运行示例,创建虚拟环境,并安装 SDK 及其同版本内置运行时: - ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -24,51 +22,48 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` -安装后的运行时不需要系统提供 Node.js。需要从源码构建运行时或 wheel 包的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.zh.md)。 +安装内容包含匹配的原生运行时 wheel 与 `dsh` 命令。普通 SDK 运行不需要系统 Node.js。需要构建产物的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.zh.md)。 -## 运行仓库内置示例 +## 运行检入示例 -请在环境中设置凭据。如果模型不是由默认 DeepSeek 端点提供,而是通过 OpenAI 兼容代理提供,还需要设置 `DEEPSEEK_BASE_URL`。 +导出凭据;使用兼容代理时再设置 endpoint: ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 -# export DSH_MODEL=deepseek-v4-flash -# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.' ``` -针对隔离的 workspace 和会话目录运行一个任务: +使用显式 workspace 与 home 路径运行一个任务: ```sh python examples/python-sdk-agent/minimal.py \ - --workspace /absolute/path/to/workspace \ - --session-root /absolute/path/to/sessions \ + --workspace /absolute/path/to/disposable-workspace \ + --dsh-home /absolute/path/to/example-dsh-home \ --session-id example-001 \ "Inspect the repository and fix the failing tests." ``` -脚本会打印 assistant 的最终回复。会话目录会收到 JSONL 日志,其中包含组装后的模型请求与工具调用。 +脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk` profile、设置、调用方添加的凭据、已安装插件,以及 `sessions/` 下的 Zstandard 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 -## 在自己的程序中使用 SDK - -仓库内置示例是以下 SDK 调用的轻量包装: +## 在程序中使用 SDK ```python from pathlib import Path from deepseek_harness import DeepSeekHarness -config = Path("examples/python-sdk-agent/minimal.cordis.yml").resolve() -workspace = Path("/absolute/path/to/workspace").resolve() -sessions = Path("/absolute/path/to/sessions").resolve() +workspace = Path("/absolute/path/to/disposable-workspace").resolve() +dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() +patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), - session_root=str(sessions), - cordis=str(config), + dsh_home=str(dsh_home), + profile="sdk", + patches=(str(patch),), ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -78,27 +73,36 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` 会延迟启动内置运行时,并持续复用,直至退出上下文管理器。复用同一个 harness 与 session id 会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。独立任务应使用新的 session id;只有下一次调用需要延续同一段持久化对话时,才复用原有 id。 +SDK 会延迟启动内置的 `dsh --profile sdk` 进程,并复用到上下文管理器退出。Profile、其持久 patch、home patch 与有序 `patches` tuple 共同组成应用配置。不存在独立 Python 运行时 bin 或完整配置选项。 -## 了解示例组合 +## 安装或定义插件 + +需要在该 home 中持久保存依赖与 bundle 层时,使用 `dsh plugin`: + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +第一个命令初始化随附的 SDK profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 + +另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 + +## 理解极简 overlay | 属性 | 值 | |---|---| -| 系统提示词 | `DSH_SYSTEM_PROMPT`;未设置时使用 `You are a helpful software engineer assistant.` | -| `minimal.py` 使用的模型 | `--model`,其次为 `DSH_MODEL`,最后为 `deepseek-v4-flash` | +| 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | +| `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | | 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | | Bash 超时 | 300 秒 | -| 编辑器输出上限 | 16,000 个字符 | -| 上下文压缩 | 已关闭 | -| 文件系统 | 裸本地后端;编辑器使用绝对路径,可以访问运行时进程可见的任何路径 | -| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL | +| Editor 输出上限 | 16,000 字符 | +| 上下文压缩 | 禁用 | +| 会话持久化 | `/sessions` 下的 Zstandard JSONL | -该组合省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具、上下文压缩和其他所有面向模型的插件。沙箱策略事实记录为运行时用户上下文,而不会追加到系统提示词中。 +该 overlay 会移除运行时上下文提示消息与大多数默认工具,但保留 SDK 应用的协议与持久化。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 -## 选择 workspace 与 session id +需要隔离 profile、插件、凭据、设置与会话时,应使用新的 home。独立工作应使用新的 session id;只有继续同一段持久对话和会话资源时,才同时复用 harness、home 与 id。 -`cwd` 用于选择 agent 可访问的 workspace,`session_root` 用于保存会话日志和状态。独立任务应使用新的 session id;只有下一次调用需要延续同一段对话和持久 shell 状态时,才复用原有 id。 - -该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该组合不支持 Windows agent。 - -准确的组合内容归 [`python-sdk-agent` 示例参考](../../../examples/python-sdk-agent/README.zh.md)所有。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知、运行时选择和配置;[Cordis primer](../../cordis-primer.zh.md)介绍组合语法。 +[示例参考](../../../examples/python-sdk-agent/README.zh.md)定义检入 overlay。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知与底层行为;[dsh CLI 参考](../../../apps/cli/reference/README.zh.md)介绍 profile 分层。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index d217628a89..1f9b897250 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-agent/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 examples/python-sdk-agent/README.md -README.md: 279aa5bcf0e988168cc936fbd6d96b69e74a5873 -README.zh.md: d843d8be719349b24e0369a2177748f2b09e40d6 +README.md: e46765b0419b0b614163fa507cadef0e5c5a53b9 +README.zh.md: 13c297bdbf01b612369ded5909ec8b7f71bab26c diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index 279aa5bcf0..e46765b041 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -2,39 +2,41 @@ English | [中文](README.zh.md) -The unattended coding-agent composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval UI, or user-questions tool because stdout belongs to the SDK protocol and turns are driven by the SDK. +Runnable Python SDK example over the sole application launcher, `dsh --profile sdk`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, permissions, and plugins. -The model-facing tools are: +## Run the minimal agent -- `bash`, foreground only -- `read`, `write`, and `edit` -- `subagent`, using one foreground in-process spawn provider -- `todo_write` +Install `deepseek-harness-sdk`, export a model credential, then supply an isolated Harness home and workspace: -The surrounding runtime also loads JSONL session persistence and automatic context compaction. `maxTokensAsSuccess` keeps a token-limited model turn as an accepted evaluation result while preserving its `max-tokens` reason. +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +python examples/python-sdk-agent/minimal.py \ + --dsh-home /absolute/path/to/example-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` -## Runtime environment +Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` and `--profile` override their script defaults. The selected home stores the generated profile and Zstandard session logs under `sessions/`; the script never reads `~/.dsh` implicitly. -| Variable | Purpose | -|---|---| -| `DEEPSEEK_API_KEY` | Credential passed to the OpenAI-compatible host endpoint | -| `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | -| `DSH_CWD` | Agent workspace for bash and filesystem tools | -| `DSH_CONTEXT_WINDOW` | Context capacity recorded for the `DSH_MODEL` catalog entry in the minimal variant | -| `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors | -| `DSH_MODEL` | Default model used by `minimal.py`; `--model` takes precedence | -| `DSH_SESSION_ROOT` | JSONL session directory | -| `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | - -Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. - -## Minimal variant - -[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. `DSH_SYSTEM_PROMPT` selects its system prompt, with `You are a helpful software engineer assistant.` as the fallback. It suppresses every system-prompt runtime-context contribution for fresh sessions and mounts no context-compaction plugin. Its model-facing tools are exactly: +[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. It preserves the SDK application bundle but narrows model-visible behavior to: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. Bash and absolute editor paths can modify any path available to the runtime process, so run this variant only against a disposable checkout or container. The persistent PTY requires a POSIX terminal environment and is not a Windows agent interface. +The patch omits Harness identity and runtime-context messages, local instruction discovery, skills, compaction, plan/goal/task/web/subagent/workflow tools, and the profile's one-shot Bash. It inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. -[`minimal.py`](minimal.py) runs the composition through the Python SDK and uses `DSH_MODEL` as its default model. The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers installation, execution, workspace selection, and session identity; the [SDK reference](../../python/sdk/README.md) owns runtime lifecycle and result semantics. +This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. + +## Add plugins + +Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes: + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +The Python call can also pass additional absolute patch paths in `patches=(...)`; later files win. A selected profile must retain `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Complete standalone Cordis files in this directory remain test fixtures for lower-level composition coverage; they are not Python SDK launch interfaces. + +See the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) and [SDK reference](../../python/sdk/README.md). diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index d843d8be71..13c297bdbf 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -2,39 +2,41 @@ [English](README.md) | 中文 -面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、控制台日志记录器、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 +基于唯一应用启动器 `dsh --profile sdk` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent 组合、持久化、权限与插件。 -面向模型的工具为: +## 运行极简 agent -- `bash`,仅前台 -- `read`、`write` 和 `edit` -- `subagent`,使用一个在进程内以前台方式运行的 spawn 提供方 -- `todo_write` +安装 `deepseek-harness-sdk`、导出模型凭据,然后提供隔离的 Harness home 与 workspace: -周边运行时还加载 JSONL 会话持久化和自动上下文压缩(context compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +python examples/python-sdk-agent/minimal.py \ + --dsh-home /absolute/path/to/example-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` -## 运行时环境 +兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 与 `--profile` 会覆盖脚本默认值。所选 home 保存生成的 profile,并在 `sessions/` 下保存 Zstandard 会话日志;脚本绝不会隐式读取 `~/.dsh`。 -| 变量 | 用途 | -|---|---| -| `DEEPSEEK_API_KEY` | 传给 OpenAI 兼容宿主端点的凭据 | -| `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | -| `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | -| `DSH_CONTEXT_WINDOW` | 极简变体中为 `DSH_MODEL` 目录项记录的上下文容量 | -| `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | -| `DSH_MODEL` | `minimal.py` 使用的默认模型;`--model` 优先 | -| `DSH_SESSION_ROOT` | JSONL 会话目录 | -| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | +[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。它保留 SDK 应用 bundle,但将模型可见行为收窄为: -通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 +- agent 所有的持久 `bash` +- 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -## 极简变体 +该 patch 会省略 Harness 身份与运行时上下文消息、本地指令发现、skill、compaction,以及 plan/goal/task/web/subagent/workflow 工具和 profile 的单次 Bash。它插入本地 PTY 与持久 Bash provider,并把 editor 输出上限设为 16,000 字符。 -[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。`DSH_SYSTEM_PROMPT` 选择它的系统提示词,未设置时使用 `You are a helpful software engineer assistant.`。它为新建会话抑制每个 system-prompt runtime-context 贡献,且不挂载上下文压缩插件。面向模型的工具严格只有: +此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 -- 所有者作用域内持久化的 `bash` -- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` +## 添加插件 -它组合了内置运行时所需的本地 PTY、裸 `fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。Bash 和编辑器绝对路径可以修改运行时进程有权访问的任何路径,因此只能针对可丢弃的 checkout 或容器运行该变体。持久 PTY 需要 POSIX 终端环境,因此不适用于 Windows agent 接口。 +对同一个显式 home 使用运行时 wheel 提供的 `dsh` 命令,以进行持久 profile 变更: -[`minimal.py`](minimal.py)通过 Python SDK 运行该组合,并把 `DSH_MODEL` 作为默认模型。[Python SDK 教程](../../docs/user/guide/python-sdk.zh.md)介绍安装、运行、workspace 选择与 session 标识;[SDK 参考](../../python/sdk/README.zh.md)归属运行时生命周期与结果语义。 +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +Python 调用也可以在 `patches=(...)` 中传入更多绝对 patch 路径;后面的文件优先。所选 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项。本目录中的完整独立 Cordis 文件仍作为底层组合测试 fixture;它们不是 Python SDK 启动接口。 + +另见 [Python SDK 教程](../../docs/user/guide/python-sdk.zh.md)与 [SDK 参考](../../python/sdk/README.zh.md)。 diff --git a/examples/python-sdk-agent/cordis.snapshot.yml b/examples/python-sdk-agent/cordis.snapshot.yml index 23bc8c5402..a054ddbe4e 100644 --- a/examples/python-sdk-agent/cordis.snapshot.yml +++ b/examples/python-sdk-agent/cordis.snapshot.yml @@ -3,10 +3,9 @@ # key or network; every other entry remains shared. The replay provider # catalog claims the `deepseek-official` provider so the SDK server's `initialize` # finds it owned and never mounts the real-adapter fallback. The SDK snapshot -# suite passes this path explicitly through `DSH_CORDIS_CONFIG` (the -# jsonrpc-demo bin performs no DSH_SNAPSHOT config swap of its own), and -# `llm-replay` reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the -# harness. Stdout remains reserved for JSON-RPC frames. +# suite selects this complete-config fixture explicitly, and `llm-replay` +# reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the harness. +# It is not a Python launch interface. Stdout remains reserved for JSON-RPC. - id: base name: '@deepseek-ai/cordis-plugin-include' config: diff --git a/examples/python-sdk-agent/cordis.yml b/examples/python-sdk-agent/cordis.yml index 40878c58b2..6a04f5f42d 100644 --- a/examples/python-sdk-agent/cordis.yml +++ b/examples/python-sdk-agent/cordis.yml @@ -1,5 +1,6 @@ -# Unattended coding-agent deployment for the bundled dsh-jsonrpc-agent runtime. -# stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. +# Complete JSON-RPC composition fixture for lower-level Loader and SDK tests. +# Python users launch `dsh --profile sdk` and apply patches instead. +# Stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. - id: sdk-jsonrpc-server name: '@deepseek-ai/dsh-sdk-jsonrpc-server' diff --git a/examples/python-sdk-agent/minimal.cordis.yml b/examples/python-sdk-agent/minimal.cordis.yml index fdf3a18e7a..f849b798a0 100644 --- a/examples/python-sdk-agent/minimal.cordis.yml +++ b/examples/python-sdk-agent/minimal.cordis.yml @@ -1,7 +1,6 @@ -# Complete unattended minimal-agent composition for the Python SDK. The model -# sees one deployment-selected system prompt and only the owner-scoped -# persistent Bash and string-replace editor tools. Runtime-context injection and -# context compaction are absent. +# Complete minimal-agent composition fixture for lower-level snapshot tests. +# Python users apply `minimal.patch.yml` to `dsh --profile sdk`. This fixture +# retains the same two-tool behavior without defining a supported launch path. - id: sdk-jsonrpc-server name: '@deepseek-ai/dsh-sdk-jsonrpc-server' diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 9478bfcb2a..4dc1bb405a 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 5e44b3821d1272923f1545e697d91a434374d24a -README.zh.md: 1bf06b993c0379ecebf8246a50e1fc5fe5f4fce1 +README.md: 6d1add5baa7c73033dadc4247a03b546e6ff9dfd +README.zh.md: da2606b93cae1a6a642a6ee15ac5f0cfc8ce6457 diff --git a/packages/README.md b/packages/README.md index 5e44b3821d..6d1add5baa 100644 --- a/packages/README.md +++ b/packages/README.md @@ -50,7 +50,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`credentials/`](credentials/README.md) | Credential reference/record seam + env-over-`.env` provider + authorization flows | Product — stable API | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API | -| [`sdk/`](sdk/README.md) | Out-of-process SDK: JSON-RPC protocol, TypeScript client/server, and private Python carrier | Product — stable API | +| [`sdk/`](sdk/README.md) | Out-of-process SDK: JSON-RPC protocol and TypeScript client/server | Product — stable API | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API | | [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API | | [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API | diff --git a/packages/README.zh.md b/packages/README.zh.md index 1bf06b993c..da2606b93c 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -50,7 +50,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`credentials/`](credentials/README.zh.md) | 凭据引用/记录 seam + 环境变量优先于 `.env` 的提供方 + 授权 flow | 产品:稳定 API | | [`storage/`](storage/README.zh.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API | | [`workspace/`](workspace/README.zh.md) | Workspace 实体 | 产品:稳定 API | -| [`sdk/`](sdk/README.zh.md) | 进程外 SDK:JSON-RPC 协议、TypeScript 客户端/服务器和私有 Python 载体 | 产品:稳定 API | +| [`sdk/`](sdk/README.zh.md) | 进程外 SDK:JSON-RPC 协议与 TypeScript 客户端/服务器 | 产品:稳定 API | | [`acp/`](acp/README.zh.md) | 仅面向自动化的 ACP(Agent Client Protocol)服务器 | 产品:稳定 API | | [`interaction/`](interaction/README.zh.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | 产品:稳定 API | | [`boot/`](boot/README.zh.md) | 共享的 app bin 启动粘合层 | 产品:稳定 API | diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index bcdd303a0d..177b740547 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 9965d6d57f4ec6cd9a93650bbd0096b059fb010b -README.zh.md: 436b19e4b2a05f4462f7636fccd3911b8ed41548 +README.md: 81c53abb799a91a2f7601f7506defbe9dc465820 +README.zh.md: 0d9e0707e0e809f4b2db0643a04619573107941a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 9965d6d57f..81c53abb79 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shared Loader boot glue for [`dsh`](../../../apps/cli/README.md) profiles and the [temporarily packaged Python SDK runtime](../../../python/README.md). The product launcher owns profile composition and process lifecycle; the direct-config helpers remain only for that held-back runtime until its later migration. +Shared Loader boot glue for [`dsh`](../../../apps/cli/README.md) profiles, including the CLI packaged by the [Python runtime wheel](../../../python/README.md). The product launcher owns profile composition and process lifecycle. Direct-config helpers serve lower-level embedders and tests; they do not define another supported application entrypoint. | Export | Role | |---|---| @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable writes real ESM proxy packages that mirror explicit exports and re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 436b19e4b2..0d9e0707e0 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -供 [`dsh`](../../../apps/cli/README.zh.md) profile 与[暂时打包的 Python SDK runtime](../../../python/README.zh.md) 共用的 Loader 启动粘合层。产品启动器负责 profile 组合与进程生命周期;直接配置 helper 只为暂缓迁移的 runtime 保留,直至后续迁移。 +供 [`dsh`](../../../apps/cli/README.zh.md) profile 共用的 Loader 启动粘合层,也用于 [Python 运行时 wheel](../../../python/README.zh.md)打包的 CLI。产品启动器负责 profile 组合与进程生命周期。直接配置 helper 服务于底层 embedder 与测试,不会定义另一个受支持的应用入口。 | 导出 | 职责 | |---|---| @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而无需由 pnpm 管理随安装内置的包。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则写入真实 ESM 代理包,镜像显式 exports 并重新导出虚拟模块 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/sdk/README.i18n.yaml b/packages/sdk/README.i18n.yaml index 05788b05a5..e46b9b6698 100644 --- a/packages/sdk/README.i18n.yaml +++ b/packages/sdk/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/sdk/README.md -README.md: 663fa95dc56ecd71c7a2639b6edb45caacbab860 -README.zh.md: 0343ad477f9fa522666cd8d4440bfe645f309c2e +README.md: 7227e9c2c93b3f870d0affc74471d7801f37d4ef +README.zh.md: 3b6f5556cb539abafd1a1e8c866f09a44a2d2f1a diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 663fa95dc5..7227e9c2c9 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,11 +2,10 @@ English | [中文](README.zh.md) -This group contains the protocol stack for driving a Harness runtime from another process. The TypeScript client launches the matching `dsh` CLI with a named profile and ordered patches; the private Python carrier preserves the current packaged direct-config runtime until Python moves through the same profile path. The [TypeScript SDK decision](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client contract, and the [toolchain removal](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.md) owns the product boundary. +This group contains the protocol stack for driving a Harness runtime from another process. The TypeScript and Python clients both launch `dsh` with a named profile and ordered patches; no package in this group defines a separate application. The [TypeScript SDK decision](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client contract, and the [Python profile-runtime decision](../../.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md) owns the packaged Python launch. | Package | Role | |---|---| | [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol | | [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API | | [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC | -| [`python-runtime/`](python-runtime/README.md) | Private direct-config carrier for the temporarily unchanged Python SDK runtime | diff --git a/packages/sdk/README.zh.md b/packages/sdk/README.zh.md index 0343ad477f..3b6f5556cb 100644 --- a/packages/sdk/README.zh.md +++ b/packages/sdk/README.zh.md @@ -2,11 +2,10 @@ [English](README.md) | 中文 -本组包含用于从另一进程驱动 Harness 运行时的协议栈。TypeScript 客户端通过具名 profile 与有序 patch 启动匹配版本的 `dsh` CLI;私有 Python 载体在 Python 迁移到同一 profile 路径之前,保留当前打包后的直读配置运行时。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)负责客户端约定,[工具链移除](../../.agents/notes/implemented/simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)负责产品边界。 +本组包含用于从另一进程驱动 Harness 运行时的协议栈。TypeScript 与 Python 客户端都通过具名 profile 与有序 patch 启动 `dsh`;本组没有任何包定义独立应用。[TypeScript SDK 决策](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)负责客户端约定,[Python profile 运行时决策](../../.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责打包后的 Python 启动。 | 包 | 职责 | |---|---| | [`protocol/`](protocol/README.zh.md) | 定义 SDK 运行时通信协议 | | [`client/`](client/README.zh.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 | | [`server/`](server/README.zh.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 | -| [`python-runtime/`](python-runtime/README.zh.md) | 为暂时保持不变的 Python SDK 运行时提供私有直读配置载体 | diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index adf84f76c4..5301b4b19e 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/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/sdk/server/README.md -README.md: e3deaa5288eb94d64a7f5a8425a829e3eed8896e -README.zh.md: a7273e9b4efbfae8e849205ea86b4c0c90588f7c +README.md: 547836ddab99d5c5a5c39c013c38b4bb25ba7e7d +README.zh.md: 97a7e9897fbf338a79c2cb33a8bab809c1cd3899 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index e3deaa5288..547836ddab 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkJsonRpcServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../protocol/README.md), shared with the client SDKs. The TypeScript client receives this server through `dsh --profile sdk`; the private [Python runtime carrier](../python-runtime/README.md) temporarily supplies a direct-config application around it. +The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkJsonRpcServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../protocol/README.md), shared with the client SDKs. TypeScript and Python clients receive this server through `dsh --profile sdk` or another profile that mounts the same row. ## Wiring -`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek-official` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding Loader composition. ## Config @@ -22,7 +22,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s ## Wire notes -`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. +`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from the surrounding composition. ## Model Experience @@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s #### What the model sees -For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`. +For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. #### Token effect diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index a7273e9b4e..97a7e9897f 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.zh.md),与客户端 SDK 共享。TypeScript 客户端通过 `dsh --profile sdk` 获得该服务器;私有 [Python 运行时载体](../python-runtime/README.zh.md)暂时为其提供直读配置应用。 +`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../protocol/README.zh.md),与客户端 SDK 共享。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 或挂载同一配置项的其他 profile 获得该服务器。 ## 组装 -`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他能力由外围 `cordis.yml` 提供。 +`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责的 `deepseek-official` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他能力由外围 Loader 组合提供。 ## 配置 @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由外围组合提供。 ## 模型体验 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自外围 `cordis.yml` 中的插件。 +对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 #### Token 影响 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 8c5a23e37b..fee9373a2d 100644 --- a/python/README.i18n.yaml +++ b/python/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 python/README.md -README.md: 75276a915eb4b63f84e0876de46e6d8d63540b59 -README.zh.md: f41822d66520869e5235a6fedd895b83a52639c9 +README.md: 7cb822e475acaa36a2a1637b0b4830e7dcabcb7a +README.zh.md: e571b936dad5d14db834fcabed363b5b7c199807 diff --git a/python/README.md b/python/README.md index 75276a915e..7cb822e475 100644 --- a/python/README.md +++ b/python/README.md @@ -9,11 +9,11 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com | Directory | Dist / module | Role | |---|---|---| | [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | -| [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled runtime binaries and default agent configuration | +| [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled `dsh` CLI executable and native sidecars | ## Behavior -The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client selects the channel and supplies default configuration; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete runtime-selection and configuration contracts. +The SDK starts the matching bundled `dsh --profile sdk` runtime unless the caller selects another `dsh` executable or profile. Every launch requires an explicitly selected Harness home; Python never silently reads `~/.dsh`. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own runtime selection, profiles, patches, and external plugin management. ## Contributor workflows diff --git a/python/README.zh.md b/python/README.zh.md index f41822d665..e571b936da 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -9,11 +9,11 @@ | 目录 | 分发名/模块 | 职责 | |---|---|---| | [sdk](sdk/README.zh.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | -| [sdk-runtime](sdk-runtime/README.zh.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | +| [sdk-runtime](sdk-runtime/README.zh.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置 `dsh` CLI 可执行程序与原生伴随文件 | ## 行为 -除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端选择通道并提供默认配置;运行时本身始终要求显式配置。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义完整的运行时选择与配置约定。 +除非调用方选择另一个 `dsh` 可执行程序或 profile,否则 SDK 会启动匹配的内置 `dsh --profile sdk` 运行时。每次启动都要求显式选择 Harness home;Python 绝不会静默读取 `~/.dsh`。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义运行时选择、profile、patch 与外部插件管理约定。 ## 贡献者工作流 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 75c657c153..bff1468133 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: e96be7af10e0008cc0fe5dea4ca51529f102fd7b -development.zh.md: e4ca1c980c36fdea381e9b8a6c615276e279b4d2 +development.md: 145f958a9df28e76456fb16a68b36fe318afa323 +development.zh.md: 557d6ba1f766dca61ab7bc73bf71ced2eb79afbe diff --git a/python/development.md b/python/development.md index e96be7af10..145f958a9d 100644 --- a/python/development.md +++ b/python/development.md @@ -31,7 +31,7 @@ That suite drives fake runtime peers. `scripts/smoke-python-runtime.py` drives t ```sh uv run --project python/sdk python scripts/smoke-python-runtime.py \ - --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 + --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the checked-in minimal composition's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job; it drops the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)). `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. @@ -43,18 +43,20 @@ An interactive smoke test needs `DEEPSEEK_API_KEY` in the environment or reposit ```python from deepseek_harness import DeepSeekHarness -with DeepSeekHarness() as harness: +with DeepSeekHarness(dsh_home="/absolute/path/to/test-dsh-home") as harness: print(harness.run("say hi").final_response) ``` +Alternatively export a non-empty `DSH_HOME`. The SDK rejects a launch that would silently use `~/.dsh`. + ## Run against Node source -Repository contributors can select either development carrier: +Repository contributors can select either development route; both execute the normal `dsh --profile sdk` launcher: - Set `DSH_RUNTIME_MODE=node` to use the built Node carrier on system Node `>=22.19`. The build script refreshes this carrier, but distributions never include or auto-select it. -- Set `launch_args_override=("./node_modules/.bin/tsx", "packages/sdk/python-runtime/src/packaged-bin.ts")` with the repository root as `cwd` to run the private carrier's unbuilt TypeScript source. Supply `cordis=...` when the default configuration is not suitable. +- Set `dsh_bin` to the absolute built `apps/cli/lib/bin.js` path to exercise the checkout's CLI directly. Supply an explicit `dsh_home`, plus `profile` and ordered `patches` as needed. -See `python/sdk/tests/manual_sdk_agent_smoke.py` for a complete source-mode invocation. +`python/sdk/tests/manual_sdk_agent_smoke.py` uses the internal `_launch_args` test adapter to exercise the unbuilt TypeScript CLI under tsx. Arbitrary argv replacement is intentionally absent from the public SDK. ## Build distributions @@ -71,7 +73,7 @@ print(release["pep440_version"](release["repository_version"]())) PY )" python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 --output-dir dist-python pip install \ "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" diff --git a/python/development.zh.md b/python/development.zh.md index e4ca1c980c..557d6ba1f7 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -31,7 +31,7 @@ uv run --project python/sdk pytest ```sh uv run --project python/sdk python scripts/smoke-python-runtime.py \ - --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 + --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` 其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定了签入的极简组合所组装的系统提示词、对外公布的工具 schema 以及模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败;它会丢弃动态运行时上下文快照——同一组合在 macOS 上会发出它,在 Linux 上不会([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 @@ -43,18 +43,20 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ ```python from deepseek_harness import DeepSeekHarness -with DeepSeekHarness() as harness: +with DeepSeekHarness(dsh_home="/absolute/path/to/test-dsh-home") as harness: print(harness.run("say hi").final_response) ``` +也可以导出非空 `DSH_HOME`。SDK 会拒绝可能静默使用 `~/.dsh` 的启动。 + ## 针对 Node 源码运行 -仓库贡献者可以选择以下任一开发载体: +仓库贡献者可以选择以下任一开发路径;两者都执行普通的 `dsh --profile sdk` 启动器: - 设置 `DSH_RUNTIME_MODE=node`,在系统 Node `>=22.19` 上使用已构建的 Node 载体。构建脚本会刷新该载体,但分发物绝不会包含或自动选择它。 -- 将仓库根目录设为 `cwd`,并设置 `launch_args_override=("./node_modules/.bin/tsx", "packages/sdk/python-runtime/src/packaged-bin.ts")`,以运行私有载体未构建的 TypeScript 源码。默认配置不合适时,请提供 `cordis=...`。 +- 将 `dsh_bin` 设置为已构建 `apps/cli/lib/bin.js` 的绝对路径,直接验证当前 checkout 的 CLI。请显式提供 `dsh_home`,并按需提供 `profile` 与有序 `patches`。 -完整的源码模式调用见 `python/sdk/tests/manual_sdk_agent_smoke.py`。 +`python/sdk/tests/manual_sdk_agent_smoke.py` 使用内部 `_launch_args` 测试适配器,通过 tsx 验证未构建的 TypeScript CLI。公开 SDK 刻意不提供任意 argv 替换。 ## 构建分发包 @@ -71,7 +73,7 @@ print(release["pep440_version"](release["repository_version"]())) PY )" python scripts/build-python-release.py --package sdk --output-dir dist-python -python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 --output-dir dist-python pip install \ "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index c4ccb18bf1..ddcef99f9e 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: 67d3842a9255250f66f22ff1f9422b26c3cf3282 -README.zh.md: 47c94b29d68eb915fae5303274fe2888274c1f82 +README.md: 52103b538be0ebc79572d8477e407f324e419ce6 +README.zh.md: e4fe7673a510102504454d5a6113ec7ec6ffd086 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 67d3842a92..52103b538b 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -1,31 +1,36 @@ -# DeepSeek Harness Runtime Wheel +# deepseek-harness-runtime-bin -English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.zh.md) +English | [中文](README.zh.md) -Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. +Platform runtime wheel for the DeepSeek Harness Python SDK. It packages the normal `dsh` CLI and its closed Node dependency tree into a native executable, so SDK use requires no system Node.js. This package publishes wheels only. -## Runtime carriers +## Installed commands and artifacts -Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: +The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Python module. `dsh` forwards its arguments to the bundled executable and requires a non-empty `DSH_HOME`; it never falls back to `~/.dsh`. -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) with a target-native ripgrep `-rg` sidecar. macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. -- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. +Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory. Linux and macOS wheels include a target-native `-rg` sidecar; macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, and macOS arm64. The wheel tag and payload must match exactly. -Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the private `dsh-sdk-python-runtime-closure` deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. +Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. -The bundled plugin set includes `@deepseek-ai/dsh-mcp-client`, so an external Cordis config can connect to stdio or Streamable HTTP MCP servers and expose their tools to the model. The wheel does not bundle MCP server programs or credentials: a stdio config supplies its executable and arguments, while a Streamable HTTP config supplies its URL and headers. The bridge supports MCP tools; MCP Resources and Prompts remain unsupported. +Both carriers execute the same `dsh` grammar and profiles. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. -A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. +## Python module API -Each wheel contains exactly one runtime executable and its matching ripgrep `-rg` sidecar. The macOS wheel also contains its matching native spawn helper; any missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use filesystem-search or PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime executables, missing or extra sidecars, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. +- `bundled_package_dir() -> Path` returns the installed module-data root and verifies its release metadata. +- `bundled_runtime_path() -> Path` returns the current platform executable and verifies required sidecars. +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` returns the executable argv by default. Explicit `mode="node"` or `DSH_RUNTIME_MODE=node` selects the repo-only Node carrier. +- `main()` implements the installed `dsh` console command and rejects an absent or blank `DSH_HOME` before replacing the Python process. -## Resolution API +Unsupported platforms and missing executables or sidecars raise `FileNotFoundError` with the build and installation routes. Unknown runtime modes raise `ValueError`. -- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only); it validates the required sibling `-rg` sidecar on every platform and the `-spawn-helper` sidecar on macOS. The node carrier has no single-path equivalent and launches via the argv tuple above. -- `bundled_default_config_path() -> Path` — the checked-in default config (see below). -- `bundled_package_dir() -> Path` — the installed package data root. +## Packaged profile resolution -## Zero-config design +`dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving interface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-sdk-jsonrpc-server`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +External profile management uses `dsh plugin --profile ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not. + +## Build and distribution + +From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. + +The installed-wheel smoke creates a clean virtual environment outside the checkout, proves distribution and executable provenance, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 47c94b29d6..e4fe7673a5 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -1,31 +1,36 @@ -# DeepSeek Harness 运行时 wheel 包 +# deepseek-harness-runtime-bin -[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) | 中文 +[English](README.md) | 中文 -Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 +DeepSeek Harness Python SDK 的平台运行时 wheel。它把普通 `dsh` CLI 及其封闭的 Node 依赖树打包成原生可执行程序,因此使用 SDK 不需要系统 Node.js。本包只发布 wheel。 -## 运行时载体 +## 安装命令与产物 -两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: +Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模块。`dsh` 将参数转发给内置可执行程序,并要求非空 `DSH_HOME`;它不会回退到 `~/.dsh`。 -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` 伴随文件。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 -- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 +生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序;macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64 与 macOS arm64。Wheel tag 必须与载荷严格匹配。 -两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的私有 `dsh-sdk-python-runtime-closure` 部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 与 sdist 均不包含它。 -内置插件集合包含 `@deepseek-ai/dsh-mcp-client`,因此外部 Cordis 配置可以连接 stdio 或 Streamable HTTP MCP server,并向模型提供这些 server 的工具。wheel 包不包含 MCP server 程序或凭据:stdio 配置需要提供可执行程序及其参数,Streamable HTTP 配置需要提供 URL 和请求头。该桥接仅支持 MCP 工具,尚不支持 MCP Resources 与 Prompts。 +两种载体执行相同的 `dsh` 语法和 profile。私有 `dsh-python-runtime-closure` manifest 定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 -exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 +## Python 模块 API -每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` 伴随文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一伴随文件都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的伴随文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 +- `bundled_package_dir() -> Path` 返回已安装模块数据根目录,并校验发布元数据。 +- `bundled_runtime_path() -> Path` 返回当前平台可执行程序,并校验必需伴随文件。 +- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` 默认返回可执行程序 argv。显式 `mode="node"` 或 `DSH_RUNTIME_MODE=node` 会选择仅限仓库使用的 Node 载体。 +- `main()` 实现已安装的 `dsh` 控制台命令,并在替换 Python 进程前拒绝缺失或空白的 `DSH_HOME`。 -## 解析 API +不支持的平台以及缺失的可执行程序或伴随文件会抛出 `FileNotFoundError`,并指出构建与安装路径。未知运行时模式会抛出 `ValueError`。 -- `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` 伴随文件,并在 macOS 上额外校验 `-spawn-helper` 伴随文件。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 -- `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 -- `bundled_package_dir() -> Path`——已安装包的数据根目录。 +## 打包后的 profile 解析 -## 零配置设计 +`dsh` 在显式 home 下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理镜像显式运行时 exports、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与原生 addon 一同打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一强制语义是运行时设计的一部分,本包不会弱化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-sdk-jsonrpc-server`),缺了它,启动出的 agent(智能体)就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、agent 核心、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统提供方。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统提供方则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +外部 profile 管理使用 `dsh plugin --profile ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。 + +## 构建与分发 + +在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 + +Installed-wheel smoke 会在 checkout 外创建干净虚拟环境,证明 distribution 与可执行程序来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。另见 [Python 贡献者工作流](../development.zh.md)与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 4b93a8d04e..7a8ef7c0fd 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: 99515c52e6314dc29788a324338699d75c0a4451 -README.zh.md: 6ec268545107478ce9f347cdfb7f17d4a8afd151 +README.md: edf5ced63f181bb17895d81cc060577916554305 +README.zh.md: e03a19e5b8f0cef2e2f9d86f8d91b8808e268ff4 diff --git a/python/sdk/README.md b/python/sdk/README.md index 99515c52e6..edf5ced63f 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -1,51 +1,64 @@ # DeepSeek Harness Python SDK -English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md) +English | [中文](README.zh.md) -Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The -runtime inherits normal DeepSeek Harness environment variables such as -`DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model -endpoints directly or point those variables at a local proxy. - -Install the `deepseek-harness-sdk` distribution from PyPI; the import module remains `deepseek_harness`: +Python subprocess SDK for driving DeepSeek Harness over newline-delimited JSON-RPC on stdio. Install `deepseek-harness-sdk`; it installs the exact same-version `deepseek-harness-runtime-bin` wheel for the current platform. ```sh python -m pip install deepseek-harness-sdk ``` -Installing `deepseek-harness-sdk` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: +## Start a runtime -```py -from deepseek_harness import DeepSeekHarness +The Python SDK has no separate application entrypoint. It launches the bundled `dsh` CLI with `--profile sdk`; the selected profile owns the JSON-RPC server, agent composition, credentials, persistence, tools, and shutdown behavior. -with DeepSeekHarness() as harness: - result = harness.run("Say hi.") -``` - -`DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished. - -By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence with an explicitly composed semantic checkpoint policy, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-sdk-jsonrpc-server` entry in the config and pass the Cordis config path. +Every launch requires an explicit Harness home. Pass `dsh_home` or provide a non-empty `DSH_HOME` in the child environment. The SDK deliberately never discovers `~/.dsh`. ```py from deepseek_harness import DeepSeekHarness with DeepSeekHarness( - provider="deepseek-official", - model="deepseek-v4-flash", - max_tokens=49_152, - cordis="examples/python-sdk-agent/cordis.yml", + dsh_home="/absolute/path/to/isolated-dsh-home", + cwd="/absolute/path/to/workspace", +) as harness: + result = harness.run("Say hi.", session_id="example-001") + +print(result.final_response) +``` + +`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. + +## Customize plugins + +Persistent customization belongs to a `dsh` profile. Initialize the shipped SDK profile and install an external bundle with the runtime wheel's `dsh` command: + +```sh +export DSH_HOME=/absolute/path/to/isolated-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +The `file:` form installs the local bundle into the profile package tree, where its peer imports reach the bundled installation fallback. The profile manifest records installed dependencies and ordered bundle layers; its `$DSH_HOME/profiles/sdk/cordis.patch.yml` is the persistent user patch. `dsh plugin` needs `pnpm` only when managing external packages. Running the SDK does not require system Node.js. + +For an invocation-specific change, pass one or more patch files. They become absolute and are forwarded in order after the profile and home patch layers: + +```py +with DeepSeekHarness( + dsh_home="/absolute/path/to/isolated-dsh-home", + profile="sdk", + patches=("/absolute/path/to/first.patch.yml", "/absolute/path/to/last.patch.yml"), ) as harness: result = harness.run("Make the requested code change.") ``` -`provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. +`profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API. -The [Python SDK tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md) provides an ordered installation and first-run path without the Web UI. The [`python-sdk-agent` example](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/python-sdk-agent/README.md) owns the complete standalone Cordis file used there. +## Results and notifications -`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end` in the interval, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the runtime protocol and raises `SdkProtocolError`. Both result fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. +`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. -`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. +`HarnessClient` retains discovered subagent ancestry for the runtime process lifetime. During `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and known descendants in wire order. `RunResult.events` contains root-session events only, so descendant output cannot replace the root response. The low-level `session_prompt()` returns the queued message id immediately; callers that bypass `Session.run()` own the later activity boundary. -The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. +The selected home stores profiles, plugins, credentials, settings, and sessions. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources. -`cwd` and `runtime_cwd` are resolved to absolute paths before subprocess launch, environment injection, and the wire handshake. The public API exposes only applied options: deployment persona and persistence belong in `cordis.yml`, while `session_root` remains the high-level convenience that sets `DSH_SESSION_ROOT`. +See the [Python tutorial](../../docs/user/guide/python-sdk.md), [`python-sdk-agent` example](../../examples/python-sdk-agent/README.md), and [runtime wheel reference](../sdk-runtime/README.md). diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 6ec2685451..e03a19e5b8 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -1,48 +1,64 @@ # DeepSeek Harness Python SDK -[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md) | 中文 +[English](README.md) | 中文 -通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 - -请从 PyPI 安装 `deepseek-harness-sdk` 分发包;导入模块仍为 `deepseek_harness`: +用于通过 stdio 上按行分隔的 JSON-RPC 驱动 DeepSeek Harness 的 Python 子进程 SDK。安装 `deepseek-harness-sdk` 时,会同时安装当前平台上版本完全相同的 `deepseek-harness-runtime-bin` wheel。 ```sh python -m pip install deepseek-harness-sdk ``` -安装 `deepseek-harness-sdk` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: +## 启动运行时 -```py -from deepseek_harness import DeepSeekHarness +Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的 `dsh` CLI;所选 profile 负责 JSON-RPC 服务器、agent 组合、凭据、持久化、工具和关闭流程。 -with DeepSeekHarness() as harness: - result = harness.run("Say hi.") -``` - -`DeepSeekHarness` 会保留其按需启动的运行时子进程,以便在多次调用之间复用。请像上例一样将其用作上下文管理器,或在使用完毕后显式调用 `close()`。 - -默认情况下,SDK 会启动 `deepseek-harness-runtime-bin` 包内置的单文件可执行程序 `dsh-jsonrpc-agent`,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置,其中包括 stdio JSON-RPC 服务器、agent core(智能体核心)、预载的 DeepSeek 适配器、采用显式组合语义检查点策略的 JSONL 会话持久化,以及本地 bash。要运行自己的插件组合,请在配置中保留 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项,并传入 Cordis 配置文件路径。 +每次启动都必须显式指定 Harness home。请传入 `dsh_home`,或在子进程环境中提供非空的 `DSH_HOME`。SDK 刻意不会发现 `~/.dsh`。 ```py from deepseek_harness import DeepSeekHarness with DeepSeekHarness( - provider="deepseek-official", - model="deepseek-v4-flash", - max_tokens=49_152, - cordis="examples/python-sdk-agent/cordis.yml", + dsh_home="/absolute/path/to/isolated-dsh-home", + cwd="/absolute/path/to/workspace", +) as harness: + result = harness.run("Say hi.", session_id="example-001") + +print(result.final_response) +``` + +`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 + +## 自定义插件 + +持久自定义属于 `dsh` profile。使用运行时 wheel 提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle: + +```sh +export DSH_HOME=/absolute/path/to/isolated-dsh-home +dsh --profile sdk --dump-default-config >/dev/null +dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +``` + +`file:` 形式会把本地 bundle 安装到 profile 包树中,使其 peer import 可以到达内置安装后备。Profile manifest 会记录已安装依赖与有序 bundle 层;`$DSH_HOME/profiles/sdk/cordis.patch.yml` 是持久用户 patch。只有管理外部包时,`dsh plugin` 才需要 `pnpm`。运行 SDK 不需要系统 Node.js。 + +对于单次调用的变更,可传入一个或多个 patch 文件。它们会转成绝对路径,并在 profile 层与 home patch 层之后按顺序传给 CLI: + +```py +with DeepSeekHarness( + dsh_home="/absolute/path/to/isolated-dsh-home", + profile="sdk", + patches=("/absolute/path/to/first.patch.yml", "/absolute/path/to/last.patch.yml"), ) as harness: result = harness.run("Make the requested code change.") ``` -`provider` 选择指定 Cordis 组合所注册的提供方路由;`model` 是该适配器解析出的模型 ID。`max_tokens` 是一个可选的正整数,用于限制根 agent 及其进程内后代在每次请求中输出的 token 数量;省略该参数时,由提供方的默认行为决定输出上限。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方专属的凭据和端点,并选择 pi-ai 已安装 catalog 中存在的任意提供方/模型组合。 +`profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。 -[Python SDK 教程](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md)提供一套无需使用 Web UI、按步骤完成安装和首次运行的流程。该教程所用的完整独立 Cordis 配置文件位于 [`python-sdk-agent` 示例](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/python-sdk-agent/README.md)中。 +## 结果与通知 -`Session.run()` 的活动区间从其提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反运行时协议,并会抛出 `SdkProtocolError`。这两个结果字段描述的是 `Session.run()` 所界定的活动区间,并不表示某项输出或结束原因在因果上归属于该提示词。steering(中途引导)、注入的上下文和其他排队工作,也可能在 agent 进入空闲状态前参与这段活动。 +`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入 idle 时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的 assistant 文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 -`HarnessClient` 会在运行时进程的整个生命周期内保留已发现的 subagent 谱系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 +`HarnessClient` 会在运行时进程的整个生命周期内保留已发现的子 agent 祖先关系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。 -也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程指定配置。注入逻辑位于 `HarnessClient.start()`,因此底层客户端按默认方式启动时也具有该行为:如果启动方式最终解析为内置运行时,且既没有设置 `cordis`,也没有设置非空的 `DSH_CORDIS_CONFIG`(运行时将空值视为未设置,注入检查也是如此),系统就会使用内置默认配置;显式指定 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 时,则会完全禁用该注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md)。 +所选 home 保存 profile、插件、凭据、设置和会话。需要隔离这些资源时应使用新的 home;独立工作应使用新的 session id。同时复用 harness 与 session id 会延续持久对话和会话资源。 -`cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露由 SDK 直接应用的选项:部署 persona 和持久化配置应在 `cordis.yml` 中定义;`session_root` 则保留为设置 `DSH_SESSION_ROOT` 的高层便捷参数。 +另见 [Python 教程](../../docs/user/guide/python-sdk.zh.md)、[`python-sdk-agent` 示例](../../examples/python-sdk-agent/README.zh.md)和[运行时 wheel 参考](../sdk-runtime/README.zh.md)。 From 9edf1b9f108e4e78f6bf2db7356e4318308afe37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:04:19 +0800 Subject: [PATCH 26/42] fix(python-sdk): harden profile runtime startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve packaged profile proxies with Node ESM import conditions from each package installation, and fail loud when an explicit runtime export or legacy main entry is missing. Serialize the shared profile fallback under the existing cross-process writer lock so concurrent dsh processes cannot observe partial proxies; either carrier now replaces the other carrier’s managed entry without manual cleanup. Give Python initialize its own 10-second default bound and name the selected profile in timeout diagnostics, while leaving ordinary agent turns unbounded by default. Package the dynamically resolved web frontend and skill-badge assets so the runtime wheel’s normal dsh profiles do not depend on pkg static-discovery accidents. Rewrite the root launch rule and every active stale SDK-runtime note to the shipped dsh profile architecture in both languages. Focused tests prove import-only and transitive package exports, lock contention, cross-carrier transitions, missing-entry failures, asset inventory, and bounded initialization. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 10 +- ...-executable-sdk-runtime-distribution.zh.md | 10 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 18 +- ...8-22-single-dsh-application-launcher.zh.md | 18 +- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 6 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 6 +- ...ipt-sdk-and-sdk-subagent-backend.i18n.yaml | 4 +- ...typescript-sdk-and-sdk-subagent-backend.md | 2 +- ...escript-sdk-and-sdk-subagent-backend.zh.md | 2 +- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 1 + apps/cli/src/bin.ts | 2 +- apps/cli/src/dump-config.ts | 5 +- apps/cli/src/profile-boot.ts | 12 +- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/package.json | 2 + packages/boot/app-boot/src/profile.ts | 92 ++++++---- packages/boot/app-boot/tests/profile.spec.ts | 164 +++++++++++++++--- packages/boot/app-boot/tsconfig.json | 3 + pnpm-lock.yaml | 6 + python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 2 + python/sdk/src/deepseek_harness/client.py | 11 +- python/sdk/tests/test_client.py | 6 +- .../build-exe-for-python-sdk-assets.spec.ts | 27 +++ scripts/build-exe-for-python-sdk.ts | 5 + 35 files changed, 321 insertions(+), 127 deletions(-) create mode 100644 scripts/build-exe-for-python-sdk-assets.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 29b7caeb50..64c9153ca6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 50e63bbc4f664f9450f01acf45888991a27517d2 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cf92f42f508f005c64f09aa0127b28ed162cede5 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: e46dbbc119e2078e44632d81b333c8be5ab9d6d7 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7a1e3f445ea1c1f03df2b349a4391534a5502c3 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 50e63bbc4f..e46dbbc119 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,27 +36,27 @@ The Python client supplies an explicit Harness home and selects the `sdk` profil Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-sdk-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all three targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. -The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-sdk-jsonrpc-server` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. +The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. ### Naming lineage -`@deepseek-ai/dsh-sdk-python-runtime` (the private carrier) → `dsh-sdk-python-runtime-closure` (the deploy manifest; no scope prefix, so it is not a dsh release package) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. +`dsh-python-runtime-closure` is the private deploy manifest and `deepseek-harness-sdk-runtime--` is the executable family. The wire `serverInfo.name` is `deepseek-harness-sdk-runtime`; the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules are `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index cf92f42f50..d7a1e3f445 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,27 +36,27 @@ Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 pa exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-sdk-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部三个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 -exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 +Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 ### 命名血统 -`@deepseek-ai/dsh-sdk-python-runtime`(私有载体)→ `dsh-sdk-python-runtime-closure`(部署 manifest;没有作用域前缀,因此不属于 dsh 发布包)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 +`dsh-python-runtime-closure` 是私有部署 manifest,`deepseek-harness-sdk-runtime--` 是可执行文件族。协议字段 `serverInfo.name` 是 `deepseek-harness-sdk-runtime`;Python 分发包名是 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名是 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 1e9ba16226..de2009b1ce 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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/architecture/2026-08-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 69188e806d9192d230d1f1b52daf27a3b30481be -2026-08-22-single-dsh-application-launcher.zh.md: a6bb1909b019ea0d386bd2f4a1bb8f5199ef1974 +2026-08-22-single-dsh-application-launcher.md: 27ec766dc62a203a3054ec67828b8897ab5ad245 +2026-08-22-single-dsh-application-launcher.zh.md: 5fb7fb4234b093a6f69446e0d9b1d7fbb7f46cf3 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 69188e806d..27ec766dc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -8,7 +8,7 @@ English | [中文](2026-08-22-single-dsh-application-launcher.zh.md) DeepSeek Harness application processes need one owner for composition, plugin resolution, environment discovery, shutdown, and user customization. A dedicated app bin with a complete `cordis.yml` creates a second lifecycle beside profile launch: plugins installed into a profile do not reach it, behavior drifts from `dsh-base`, and SDK callers learn arbitrary process argv instead of the product's composition model. -The Python SDK distributes a native executable and three platform wheels whose embedded direct-config runtime cannot change launch architecture without rebuilding and validating the complete VFS closure. That distribution needs an explicit temporary exception, not a second general Node application pattern. +The Python SDK distributes a native executable and three platform wheels. Its packaged process must use the same profile launcher while preserving the closed VFS dependency tree, native sidecars, and installed-wheel evidence. ## Decision @@ -43,19 +43,15 @@ SDK users customize plugins through profiles. `dsh plugin --profile ...` Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inherited `DSH_HOME`, then `~/.dsh`. `subagent-dsh-sdk` instead requires an explicit absolute home, so a nested runtime cannot discover a person's profiles, installed plugins, credentials, or sessions through the operating-system home. DSH-specific ACP child examples also pass an isolated home; the ACP backend itself remains generic for non-DSH agents. -### Python exception and names +### Python runtime -The Python SDK's direct-config application lives in the private `packages/sdk/python-runtime` package named `@deepseek-ai/dsh-sdk-python-runtime`. Its only packaged executable entry is `lib/packaged-bin.js`, consumed by the private `dsh-sdk-python-runtime-closure` deploy root. It has no public npm bin. The runnable direct Python example is `examples/python-sdk-agent`. +The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk`, ordered patch files, and an explicit Harness home; the installed `dsh` console command exposes the same profile grammar. The runnable Python example is `examples/python-sdk-agent`. -Python-observable behavior remains fixed: Python API, SDK wire, default `cordis.yml`, environment variables, wheel distribution names, packaged executable names, sidecar names, explicit runtime options, zero-config behavior, and supported platforms. The stable SDK family remains `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, `@deepseek-ai/dsh-sdk-jsonrpc-server`, and wire identity `deepseek-harness-sdk-runtime`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. +The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. ### Enforcement -`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, test support, and the private Python carrier. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates. - -## Deferred Python migration - -The Python runtime follow-up must move the packaged process through `dsh --profile sdk`, preserve the wheel's closed dependency and native sidecar behavior, and delete `@deepseek-ai/dsh-sdk-python-runtime`. Only after those conditions pass on Linux x64, Linux arm64, and macOS arm64 does the executable family change from `dsh-jsonrpc-agent-pkg--` to `deepseek-harness-sdk-runtime--`. The temporary carrier and current artifact names make that obligation visible without weakening current Python compatibility. +`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, and test support. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates. ## Existing decisions and supersession @@ -88,7 +84,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o - Focused unit suites cover profile launch resolution, initialization bounds, SDK retries, server readiness, and nested isolated homes with 100% coverage on the changed runtime sources. - Keyless ACP and SDK snapshots boot real `dsh` profiles and pin protocol output plus persisted logs; the nested SDK composition boots a second real profile runtime. - The real-API workflow caps file parallelism at four because one profile e2e file can own several complete `dsh` subprocess trees; workflow tests pin that resource bound. -- The Python suite exercises exe and node carriers; all packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes retain the existing artifact names. +- The Python suite exercises exe and node carriers; packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes pin the `deepseek-harness-sdk-runtime-*` artifacts and profile launch. - `verify-application-entrypoints` includes invalid fixtures for package bins, executable sources, package-launching demo wrappers, and unclassified demos. ## Consequences @@ -98,4 +94,4 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o - SDK and ACP share the complete base application and one set of policy and tools; snapshots present intentional assembled differences explicitly. - Adding `@deepseek-ai/dsh` increases the TypeScript client's install size in exchange for a deterministic same-version runtime. - Trusted user patches can add a plugin that writes to stdout and corrupt their own protocol stream; shipped profiles guarantee purity, not arbitrary third-party composition. -- Python keeps a visibly private, narrowly allowed direct-config carrier until its platform artifact migration is independently proven. +- Python packages the ordinary `dsh` profile launcher while retaining a closed native runtime and no system-Node requirement for wheel users. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index a6bb1909b0..5fb7fb4234 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -8,7 +8,7 @@ Status: implemented DeepSeek Harness 应用进程需要由同一个机制负责组合、插件解析、环境发现、关闭和用户自定义。带完整 `cordis.yml` 的专用应用 bin 会在 profile 启动之外形成第二套生命周期:安装到 profile 的插件无法到达它,行为会与 `dsh-base` 偏离,SDK 调用方还需要学习任意进程 argv,而不是产品的组合模型。 -Python SDK 分发一个原生可执行文件和三个平台 wheel 包;其中嵌入的直读配置运行时只有在重建并验证完整 VFS 闭包后才能改变启动架构。该分发需要一个明确的临时例外,而不是另一种通用 Node 应用模式。 +Python SDK 分发一个原生可执行文件和三个平台 wheel 包。其打包进程必须使用同一 profile 启动器,同时保留封闭的 VFS 依赖树、原生伴随文件与 installed-wheel 证据。 ## Decision @@ -43,19 +43,15 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 直接使用 SDK 时遵循普通 Harness home 解析:显式 `dshHome`、继承的 `DSH_HOME`,最后是 `~/.dsh`。`subagent-dsh-sdk` 则要求显式绝对 home,因此嵌套运行时不会通过操作系统 home 发现个人 profile、已安装插件、凭据或会话。DSH 专用 ACP 子进程示例同样传入隔离 home;ACP 后端自身继续适用于非 DSH agent。 -### Python 例外与命名 +### Python 运行时 -Python SDK 的直读配置应用位于私有 `packages/sdk/python-runtime` 包,名称是 `@deepseek-ai/dsh-sdk-python-runtime`。它唯一的打包可执行入口是 `lib/packaged-bin.js`,由私有 `dsh-sdk-python-runtime-closure` 部署根消费。它没有公开 npm bin。可运行的直启 Python 示例是 `examples/python-sdk-agent`。 +Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端选择 `dsh --profile sdk`、有序 patch 文件和显式 Harness home;安装的 `dsh` 控制台命令暴露相同 profile 语法。可运行的 Python 示例是 `examples/python-sdk-agent`。 -Python 可观察行为保持不变:Python API、SDK 协议格式、默认 `cordis.yml`、环境变量、wheel 包分发名称、打包可执行文件名称、伴随文件名称、显式运行时选项、零配置行为与支持平台。稳定 SDK 包族继续是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol`、`@deepseek-ai/dsh-sdk-jsonrpc-server`,协议 identity 继续是 `deepseek-harness-sdk-runtime`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 +可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 ### 强制校验 -`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具、测试支持以及私有 Python 载体进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。 - -## 暂缓的 Python 迁移 - -Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk`,保持 wheel 包的封闭依赖与原生伴随文件行为,并删除 `@deepseek-ai/dsh-sdk-python-runtime`。只有这些条件在 Linux x64、Linux arm64 与 macOS arm64 全部通过后,可执行文件族才会从 `dsh-jsonrpc-agent-pkg--` 改名为 `deepseek-harness-sdk-runtime--`。临时载体与当前产物名称使这项义务清晰可见,同时不削弱当前 Python 兼容性。 +`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具和测试支持进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。 ## 既有决策与取代关系 @@ -88,7 +84,7 @@ Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk` - 聚焦单元套件覆盖 profile 启动解析、初始化时限、SDK 重试、服务器就绪和嵌套隔离 home,并对变更后的运行时源码实现 100% 覆盖率。 - 免密钥 ACP 与 SDK 快照启动真实 `dsh` profile,并钉住协议输出与持久化日志;嵌套 SDK 组合会启动第二个真实 profile 运行时。 - 真实 API 工作流把文件并行度限制为 4,因为一个 profile e2e 文件可能拥有多个完整 `dsh` 子进程树;工作流测试会钉住该资源上限。 -- Python 套件同时测试 exe 与 node 载体;全部打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试都保留既有产物名称。 +- Python 套件同时测试 exe 与 node 载体;打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试会钉住 `deepseek-harness-sdk-runtime-*` 产物与 profile 启动。 - `verify-application-entrypoints` 包含包 bin、可执行源码、直启包的 demo wrapper 与未分类 demo 等非法 fixture(测试前置数据)。 ## 影响 @@ -98,4 +94,4 @@ Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk` - SDK 与 ACP 共享完整 base 应用和同一份策略与工具;快照以显式差异呈现刻意采用的组装变化。 - 增加 `@deepseek-ai/dsh` 会扩大 TypeScript 客户端的安装体积,换来确定的同版本运行时。 - 受信任用户 patch 可以增加写入 stdout 的插件并破坏自己的协议流;随附 profile 保证纯净,不为任意第三方组合提供保证。 -- Python 保留一个清晰可见的私有直读配置载体,直到其平台产物迁移得到独立证明。 +- Python 打包普通 `dsh` profile 启动器,同时保留封闭原生运行时,wheel 用户无需系统 Node。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 2e98550ed0..17e40be1db 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 400915155460598e754c2f81d60b26e693ff438a -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: a3c9074817d1c1e9b3f59fbe5fb02910d20e02f4 +2026-08-23-python-sdk-dsh-profile-runtime.md: 14c58c85923aac1b54362c72f61f7f1e1c145124 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 9f584ca9bfcc7907caede70ef7e9781f2bc37aab diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 4009151554..14c58c8592 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -16,13 +16,13 @@ A normal profile cannot be adopted only at the Python wrapper. The runtime execu The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. -The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, and timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. +The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, a bounded initialization timeout, and optional turn/shutdown timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME` in the child environment. The SDK never discovers `~/.dsh`. The selected home consistently owns profiles, external plugins, credentials, settings, and sessions. ### Plugin customization -Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback. +Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. @@ -30,7 +30,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. -Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy mirrors the source package's explicit runtime exports and identity and re-exports its virtual module URLs. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit runtime exports under ESM import conditions and re-exports its virtual module URLs. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index a3c9074817..9f584ca9bf 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -16,13 +16,13 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 -公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择与 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 +公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择、有界初始化 timeout,以及可选的轮次/关闭 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 每次 Python 启动都要求显式 `dsh_home`,或子进程环境中的非空 `DSH_HOME`。SDK 绝不会发现 `~/.dsh`。所选 home 统一拥有 profile、外部插件、凭据、设置与会话。 ### 插件自定义 -持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退。 +持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 @@ -30,7 +30,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 -普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理镜像源包的显式运行时 exports 与身份,并重新导出其虚拟模块 URL。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理按 ESM import 条件解析源包的显式运行时 exports,并重新导出其虚拟模块 URL。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index 1d9c28595a..06e2503aa4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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-07-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: eda9d3a3de91944a298070d6cc22f632294f7a28 -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 1a72c6b1cdb7453b8468d6e6be37aec76323f714 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 37c341e964b556c7ab5fdd9081416883066b97d1 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: e97e951028de3bcda9fe11be0351072481c72dd9 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index eda9d3a3de..37c341e964 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -17,7 +17,7 @@ Three packages, layered exactly like the existing Python stack, plus one Service - **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, `provider`/`model` feeds the child's `initialize`, and `env` supplies explicit child-only values such as its API key. - **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. -`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); the private `@deepseek-ai/dsh-sdk-python-runtime` carrier consumes the shared protocol through its packaged closure. +`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical). TypeScript and Python clients both consume the shared protocol through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index 1a72c6b1cd..e97e951028 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -17,7 +17,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ - **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`/profile/patch/home 配置选择隔离的 SDK 应用,`provider`/`model` 写入子进程 `initialize`,`env` 则提供子进程专用的显式值,例如其 API key。 - **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 -`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);私有 `@deepseek-ai/dsh-sdk-python-runtime` 载体通过其打包闭包消费共享协议。 +`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致)。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 消费共享协议;Python wheel 会打包该 CLI 及其封闭依赖树。 ## 测试 diff --git a/AGENTS.md b/AGENTS.md index 1fd35bb6c9..fa9515604b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ DeepSeek Harness is an all-plugin agent harness on vendored Cordis. Read [docs/a ## Application launch -Node apps launch only through `dsh` profiles; application-package bins, demos, and SDK argv escape hatches are forbidden. The private Python runtime is the sole temporary exception. [Architecture](docs/architecture.md#application-launch) owns scope and deferred artifact rename; `pnpm run verify-application-entrypoints` enforces it. +Supported Node applications launch only through `dsh` profiles; application-package bins, demos, and public SDK argv escape hatches are forbidden. [Architecture](docs/architecture.md#application-launch) owns the launch set; `pnpm run verify-application-entrypoints` enforces it. ## Repository layout diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f2637fe699..347f3b035e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,6 +66,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | +| [`import-meta-resolve`](https://github.com/wooorm/import-meta-resolve) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 321849f2d9..9e7c600c8d 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -41,7 +41,7 @@ switch (invocation.mode) { } case 'dump-config': { const { runDumpConfig } = await import('./dump-config.ts') - runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) + await runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) break } default: diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 1754eb4efd..dc2089e7c8 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -26,9 +26,10 @@ const NAME = 'dsh' * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then * never parsed). * @param patches - `--patch` overlay paths, in argv order. + * @returns settlement after the profile is healed and the dump is written. */ -export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void { - const loaded = prepareProfile(profile, !defaultOnly) +export async function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): Promise { + const loaded = await prepareProfile(profile, !defaultOnly) const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({ label: layer.packageName, patches: layer.patches, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 68c66a8e34..1c77b1ff8a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -115,8 +115,8 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump). * @returns the loaded profile. */ -export function prepareProfile(name: string, userLayer = true): Profile { - healProfilesModuleFallback(INSTALL_ANCHOR) +export async function prepareProfile(name: string, userLayer = true): Promise { + await healProfilesModuleFallback(INSTALL_ANCHOR) const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer }) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG) return profile @@ -154,11 +154,11 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * @param patchFiles - `--patch` overlay paths, in argv order. * @returns the profile and its patch layers. */ -function composeProfile( +async function composeProfile( name: string, patchFiles: readonly string[], -): ComposedProfile { - const profile = prepareProfile(name) +): Promise { + const profile = await prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) @@ -207,7 +207,7 @@ function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { - const composed = composeProfile(options.profile, options.patchFiles) + const composed = await composeProfile(options.profile, options.patchFiles) const app: { current?: Context } = {} const appReady = createAppReady() const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1dc66b2f08..a4e048b5fd 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -107,7 +107,7 @@ async function bootWeb( // upward walk. The flat fallback the preset boot maintains is what makes // them resolvable — the same mechanism, not a test-only shim. const home = dirname(settingsFile) - healProfilesModuleFallback(INSTALL_ANCHOR, home) + await healProfilesModuleFallback(INSTALL_ANCHOR, home) const profileDir = join(home, 'profiles', 'spec') await mkdir(profileDir, { recursive: true }) // Product Bundles are installed into the Profile, not the dsh app. Model diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 51ae01105f..ec30bc1e0c 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -519,7 +519,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/profiles. - healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) + await healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) const profileDir = join(harnessHome, 'profiles', 'scaffold') await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 177b740547..6bd721e291 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 81c53abb799a91a2f7601f7506defbe9dc465820 -README.zh.md: 0d9e0707e0e809f4b2db0643a04619573107941a +README.md: f48abfa445e4ac604b5e1bc694f8bae395b566e7 +README.zh.md: e903c74fb12fee0fca1d201d217dd28be8b7e08d diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 81c53abb79..f48abfa445 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable writes real ESM proxy packages that mirror explicit exports and re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves exports with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 0d9e0707e0..e903c74fb1 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则写入真实 ESM 代理包,镜像显式 exports 并重新导出虚拟模块 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则按 Node ESM import 条件解析 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 0b4b1d7a74..96594a9117 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -32,6 +32,8 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "import-meta-resolve": "^4.2.0", "js-yaml": "^4.2.0" }, "peerDependencies": { diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e886f759b2..3f2a3d11c0 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -27,10 +27,12 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { resolve as resolveImport } from 'import-meta-resolve' import { loadOverlayPatches } from './index.ts' /** Directory under the Harness home holding every profile. */ @@ -205,7 +207,16 @@ export function initProfile( if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE) } -/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */ +function readModuleProxyRecord(link: string): ModuleProxyRecord | undefined { + try { + return JSON.parse(readFileSync(join(link, 'package.json'), 'utf8')) as ModuleProxyRecord + } catch { + // Missing or invalid metadata is not managed state; callers reject it. + return undefined + } +} + +/** Ensure `link` is a symlink to `target`, replacing a wrong link or a dsh-managed packaged proxy. */ function ensureSymlink(link: string, target: string): void { let stat try { @@ -217,12 +228,19 @@ function ensureSymlink(link: string, target: string): void { } if (stat !== undefined) { if (!stat.isSymbolicLink()) { - throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) + const existing = stat.isDirectory() ? readModuleProxyRecord(link) : undefined + if (existing?.dsh?.moduleFallback?.targets === undefined) { + throw new Error(`dsh: ${link} exists and is not a symlink or dsh-managed module proxy; remove it so dsh can manage the installation fallback`) + } + rmSync(link, { recursive: true }) + stat = undefined + } + if (stat !== undefined) { + if (readlinkSync(link) === target) return + // unlink deletes the reparse point itself on Windows too; rmSync treats a + // junction as a directory and throws EISDIR unless recursive. + unlinkSync(link) } - if (readlinkSync(link) === target) return - // unlink deletes the reparse point itself on Windows too; rmSync treats a - // junction as a directory and throws EISDIR unless recursive. - unlinkSync(link) } try { symlinkSync(target, link, 'junction') @@ -258,29 +276,40 @@ function isPackagedExecutable(): boolean { return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined } -/** Resolve one package specifier from the dsh installation. */ -function packageEntryFromAnchor(anchor: string, specifier: string): string | undefined { +/** Resolve one package export with the ESM conditions used by the generated proxy. */ +function packageEntryFromPackage(packageName: string, packageDir: string, specifier: string): string { try { - return createRequire(anchor).resolve(specifier) - } catch { - return undefined + const resolved = resolveImport(specifier, pathToFileURL(join(packageDir, 'package.json')).href) + if (!resolved.startsWith('file:') || !existsSync(fileURLToPath(resolved))) { + throw new Error(`resolved to missing or non-file URL ${resolved}`) + } + return resolved + } catch (error) { + throw new Error(`dsh: cannot resolve ESM export ${specifier} from installed package ${packageName}`, { cause: error }) } } /** Resolve every explicit runtime export that an out-of-tree plugin can import. */ function packageProxySource( - installAnchor: string, packageName: string, packageDir: string, ): { version: string; targets: Record } { const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { exports?: unknown + main?: unknown version?: unknown } if (typeof manifest.version !== 'string' || manifest.version.length === 0) { throw new Error(`dsh: installed package ${packageName} must declare a non-empty version`) } const declared = manifest.exports + if (declared === undefined) { + const entry = join(packageDir, typeof manifest.main === 'string' ? manifest.main : 'index.js') + if (!existsSync(entry)) { + throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`) + } + return { version: manifest.version, targets: { '.': pathToFileURL(entry).href } } + } const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared) && Object.keys(declared).some(key => key.startsWith('.')) ? Object.keys(declared).filter(key => key === '.' || (key.startsWith('./') && !key.includes('*') && key !== './package.json')) @@ -288,8 +317,7 @@ function packageProxySource( const targets: Record = {} for (const subpath of subpaths) { const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) - const entry = packageEntryFromAnchor(installAnchor, specifier) - if (entry !== undefined) targets[subpath] = pathToFileURL(entry).href + targets[subpath] = packageEntryFromPackage(packageName, packageDir, specifier) } return { version: manifest.version, targets } } @@ -328,18 +356,13 @@ function ensureModuleProxy( stat = undefined } if (stat !== undefined) { - const marker = join(link, 'package.json') - let existing: ModuleProxyRecord | undefined - try { - existing = JSON.parse(readFileSync(marker, 'utf8')) as ModuleProxyRecord - } catch { - existing = undefined - } + const existing = readModuleProxyRecord(link) if (existing?.dsh?.moduleFallback?.targets === undefined) { throw new Error(`dsh: ${link} exists and is not a dsh-managed module proxy; remove it so dsh can manage the installation fallback`) } if (existing.version === version - && JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets)) return + && JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets) + && Object.keys(targets).every((_, index) => existsSync(join(link, `entry-${index}.js`)))) return rmSync(link, { recursive: true }) } mkdirSync(link, { recursive: true }) @@ -357,10 +380,12 @@ function ensureModuleProxy( * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one * entry per package in the dsh app's resolvable dependency CLOSURE (BFS * over `dependencies` from the app manifest), each resolved from its own - * installation location. Plain Node uses symlinks. A pkg executable writes - * small ESM proxy packages instead because the host filesystem cannot follow - * a symlink into pkg's virtual `/snapshot` tree; the proxy re-exports the - * virtual URL, preserving the executable's single module instance. Node's + * installation location. Plain Node uses symlinks. A pkg executable resolves + * exports under ESM import conditions and writes small proxy packages because + * the host filesystem cannot follow a symlink into pkg's virtual `/snapshot` + * tree; the proxy re-exports the virtual URL, preserving the executable's + * single module instance. One cross-process writer lock prevents partial + * proxies and serializes carrier transitions. Node's * parent-directory walk from any profile finds this * directory after the profile's own `node_modules`, so every in-box plugin * resolves without pnpm ever managing it — the exact "bundles come from the @@ -375,11 +400,20 @@ function ensureModuleProxy( * reused because resolution cannot discover it. * @param installAnchor - absolute path of the dsh app's package.json. * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @returns settlement after the locked fallback generation is complete. */ -export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void { +export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise { const profilesDir = join(home, PROFILES_DIR) const modulesDir = join(profilesDir, 'node_modules') mkdirSync(modulesDir, { recursive: true }) + await withFileLock(modulesDir, () => { + healProfilesModuleFallbackLocked(installAnchor, modulesDir) + return Promise.resolve() + }) +} + +/** Heal one module-fallback generation while the cross-process writer lock is held. */ +function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: string): void { const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest const links = new Map() /* v8 ignore next -- a real app manifest always declares its name */ @@ -407,7 +441,7 @@ export function healProfilesModuleFallback(installAnchor: string, home: string = const link = join(modulesDir, packageName) mkdirSync(dirname(link), { recursive: true }) if (isPackagedExecutable()) { - const source = packageProxySource(installAnchor, packageName, target) + const source = packageProxySource(packageName, target) if (Object.keys(source.targets).length > 0) { ensureModuleProxy(link, packageName, source.version, source.targets) } diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 3710859380..6eb37bc65a 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -4,9 +4,10 @@ * empty-root composition, and the installation module-fallback healing. */ -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import { describe, expect, it } from 'vitest' import { composeEntries, @@ -271,7 +272,7 @@ describe('composeEntries', () => { }) describe('healProfilesModuleFallback', () => { - it('links the app and bundle dependency surface flat under profiles/node_modules', () => { + it('links the app and bundle dependency surface flat under profiles/node_modules', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } }, 'plain-lib': {}, @@ -285,7 +286,7 @@ describe('healProfilesModuleFallback', () => { mkdirSync(join(modules, 'dep-of-a'), { recursive: true }) writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' })) const home = tmp() - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const fallback = join(home, 'profiles', 'node_modules') // App deps, the bundle's own deps, and the bundle itself are linked; the // plain library is linked as an app dep (harmless), the app itself too. @@ -293,42 +294,67 @@ describe('healProfilesModuleFallback', () => { expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true) } // Idempotent, and a moved target is re-pointed. - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const before = readlinkSync(join(fallback, 'dep-of-a')) expect(before).toContain('dep-of-a') }) - it('throws when a fallback entry is a real directory', () => { + it('throws when a fallback entry is a foreign file or directory', async () => { const anchor = stageInstallation({}) - const home = tmp() - mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true }) - expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink') + for (const kind of ['file', 'directory']) { + const home = tmp() + const entry = join(home, 'profiles', 'node_modules', 'dsh-app') + mkdirSync(join(entry, '..'), { recursive: true }) + if (kind === 'directory') mkdirSync(entry) + else writeFileSync(entry, '') + await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow('is not a symlink') + } }) - it('replaces a wrong symlink', () => { + it('replaces a wrong symlink', async () => { const anchor = stageInstallation({}) const home = tmp() const fallback = join(home, 'profiles', 'node_modules') mkdirSync(fallback, { recursive: true }) symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction') - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') }) - it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => { - // The EEXIST arm: a second process wrote the link between our lstat miss - // and symlinkSync. Simulated by pre-creating the correct link and calling - // the internal path through a stale-lstat shim is not possible from - // outside, so probe the observable contract: healing twice concurrently - // is a no-op, and a foreign REAL directory still fails loud. + it('serializes concurrent healers and retains the identical link', async () => { const anchor = stageInstallation({}) const home = tmp() - healProfilesModuleFallback(anchor, home) - healProfilesModuleFallback(anchor, home) // second healer sees the correct link + await Promise.all([ + healProfilesModuleFallback(anchor, home), + healProfilesModuleFallback(anchor, home), + ]) const fallback = join(home, 'profiles', 'node_modules') expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) }) + it('waits for the module-fallback writer lock before publishing entries', async () => { + const anchor = stageInstallation({}) + const home = tmp() + const modules = join(home, 'profiles', 'node_modules') + mkdirSync(modules, { recursive: true }) + let releaseLock: (() => void) | undefined + let reportLock: (() => void) | undefined + const lockHeld = new Promise((resolve) => { reportLock = resolve }) + const release = new Promise((resolve) => { releaseLock = resolve }) + const holder = withFileLock(modules, async () => { + reportLock?.() + await release + }) + await lockHeld + + const healer = healProfilesModuleFallback(anchor, home) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(existsSync(join(modules, 'dsh-app'))).toBe(false) + releaseLock?.() + await Promise.all([holder, healer]) + expect(lstatSync(join(modules, 'dsh-app')).isSymbolicLink()).toBe(true) + }) + it('writes real ESM proxies for a packaged executable', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') @@ -339,7 +365,7 @@ describe('healProfilesModuleFallback', () => { const home = tmp() Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const fallback = join(home, 'profiles', 'node_modules') const proxy = join(fallback, 'bundle-a') expect(lstatSync(proxy).isDirectory()).toBe(true) @@ -355,13 +381,79 @@ describe('healProfilesModuleFallback', () => { expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js')) await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' }) - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) } finally { delete (process as NodeJS.Process & { pkg?: unknown }).pkg } }) - it('requires a package version before writing a packaged proxy', () => { + it('resolves import-only exports from each package installation', async () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '[]\n', deps: { 'nested-esm': '0.0.0' } }, + }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + bundleManifest.exports = { '.': { import: './index.js' } } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) + const nestedDir = join(bundleDir, 'node_modules', 'nested-esm') + mkdirSync(nestedDir, { recursive: true }) + writeFileSync(join(nestedDir, 'package.json'), JSON.stringify({ + name: 'nested-esm', + version: '0.0.0', + type: 'module', + exports: { import: './index.js' }, + })) + writeFileSync(join(nestedDir, 'index.js'), 'export const nested = "proxied"\n') + const home = tmp() + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + await expect(import(join(fallback, 'bundle-a', 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) + await expect(import(join(fallback, 'nested-esm', 'entry-0.js'))).resolves.toMatchObject({ nested: 'proxied' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('uses the legacy index fallback when a package has no exports or main', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + delete manifest.main + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) + .resolves.toMatchObject({ packageName: 'bundle-a' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('fails loud on a missing legacy main entry or ESM export target', async () => { + for (const mode of ['legacy', 'exports']) { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + if (mode === 'legacy') delete manifest.main + else manifest.exports = { '.': { import: './missing.js' } } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + rmSync(join(bundleDir, 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow( + mode === 'legacy' ? 'main entry is missing' : 'cannot resolve ESM export bundle-a', + ) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + } + }) + + it('requires a package version before writing a packaged proxy', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record @@ -369,7 +461,7 @@ describe('healProfilesModuleFallback', () => { writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - expect(() => { healProfilesModuleFallback(anchor, tmp()) }).toThrow( + await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow( 'installed package bundle-a must declare a non-empty version', ) } finally { @@ -377,23 +469,23 @@ describe('healProfilesModuleFallback', () => { } }) - it('replaces plain-node links and stale managed proxies in packaged mode', () => { + it('replaces plain-node links and stale managed proxies in packaged mode', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const home = tmp() - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') expect(lstatSync(proxy).isSymbolicLink()).toBe(true) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) expect(lstatSync(proxy).isDirectory()).toBe(true) const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { version: string } stale.version = 'stale' writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale)) - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback(anchor, home) expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({ version: '0.0.0', }) @@ -402,7 +494,23 @@ describe('healProfilesModuleFallback', () => { } }) - it('rejects foreign packaged fallback directories with valid or invalid metadata', () => { + it('replaces a managed packaged proxy with a plain-node symlink', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules', 'bundle-a') + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback(anchor, home) + expect(lstatSync(fallback).isDirectory()).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + + await healProfilesModuleFallback(anchor, home) + expect(lstatSync(fallback).isSymbolicLink()).toBe(true) + }) + + it('rejects foreign packaged fallback directories with valid or invalid metadata', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { @@ -411,7 +519,7 @@ describe('healProfilesModuleFallback', () => { const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') mkdirSync(proxy, { recursive: true }) writeFileSync(join(proxy, 'package.json'), metadata) - expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow( + await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow( 'exists and is not a dsh-managed module proxy', ) } diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index 6f0e03b8fb..c866abbc25 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/atomic-write" + }, { "path": "../../util/launch-environment" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9deab88d6b..c7c4e7d7f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1145,6 +1145,12 @@ importers: packages/boot/app-boot: dependencies: + '@deepseek-ai/dsh-atomic-write': + specifier: workspace:^ + version: link:../../util/atomic-write + import-meta-resolve: + specifier: ^4.2.0 + version: 4.2.0 js-yaml: specifier: ^4.2.0 version: 4.2.0 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 7a8ef7c0fd..29b3a5a70b 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: edf5ced63f181bb17895d81cc060577916554305 -README.zh.md: e03a19e5b8f0cef2e2f9d86f8d91b8808e268ff4 +README.md: ef2ba1764aa93fc6499941dd372a1de94f33d454 +README.zh.md: b5c36b6ff0fbce303649ae92efd7e1edac954e82 diff --git a/python/sdk/README.md b/python/sdk/README.md index edf5ced63f..ef2ba1764a 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. +`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 10-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. ## Customize plugins diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index e03a19e5b8..b5c36b6ff0 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 +`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 10 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 ## 自定义插件 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 1b33524118..09286a1ad1 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -29,6 +29,7 @@ class DeepSeekHarnessConfig: patches: tuple[str, ...] = () dsh_home: str | None = None env: dict[str, str] = field(default_factory=dict) + initialize_timeout_seconds: float = 10.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None @@ -79,6 +80,7 @@ class DeepSeekHarness: dsh_home=self.config.dsh_home, cwd=runtime_cwd, env=env, + initialize_timeout_seconds=self.config.initialize_timeout_seconds, request_timeout_seconds=self.config.request_timeout_seconds, shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, ), diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 8969bc7901..5978d849ab 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -31,6 +31,7 @@ class HarnessConfig: dsh_home: str | None = None cwd: str | None = None env: dict[str, str] | None = None + initialize_timeout_seconds: float = 10.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 _launch_args: tuple[str, ...] | None = None @@ -137,7 +138,15 @@ class HarnessClient: if max_tokens is not None: payload["maxTokens"] = max_tokens try: - return self.request("initialize", payload, response_model=InitializeResponse) + return self.request( + "initialize", + payload, + response_model=InitializeResponse, + timeout_seconds=self.config.initialize_timeout_seconds, + ) + except TimeoutError as error: + self.close() + raise TimeoutError(f"{error}\nselected dsh profile {self.config.profile!r}") from error except BaseException as error: self.close() diagnostics = self._runtime_diagnostics() diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index f1190407c9..1ad89ffd4b 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -731,7 +731,8 @@ time.sleep(60) with HarnessClient( HarnessConfig( _launch_args=(sys.executable, str(script)), - request_timeout_seconds=0.1, + profile="web", + initialize_timeout_seconds=0.1, ) ) as client: start = time.monotonic() @@ -740,6 +741,7 @@ time.sleep(60) except TimeoutError as exc: assert time.monotonic() - start < 2 assert "bridge is still starting" in str(exc) + assert "profile 'web'" in str(exc) else: raise AssertionError("initialize should time out") @@ -832,6 +834,8 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set( HarnessConfig.__dataclass_fields__ ) + assert "initialize_timeout_seconds" in DeepSeekHarnessConfig.__dataclass_fields__ + assert "initialize_timeout_seconds" in HarnessConfig.__dataclass_fields__ for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ assert removed not in HarnessConfig.__dataclass_fields__ diff --git a/scripts/build-exe-for-python-sdk-assets.spec.ts b/scripts/build-exe-for-python-sdk-assets.spec.ts new file mode 100644 index 0000000000..27167c5307 --- /dev/null +++ b/scripts/build-exe-for-python-sdk-assets.spec.ts @@ -0,0 +1,27 @@ +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '..') +const script = resolve(root, 'scripts/build-exe-for-python-sdk.ts') + +describe('Python runtime executable assets', () => { + it('packages the dynamically resolved web frontend distribution', () => { + const result = spawnSync(process.execPath, [ + '--import', + 'tsx/esm', + script, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, npm_execpath: 'C:\\tools\\pnpm.cjs' }, + }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*') + expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*') + }) +}) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index f7d696edfe..c8c30b4301 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -47,6 +47,7 @@ const ASSET_GLOBS = [ 'node_modules/**/*.mjs', 'node_modules/**/package.json', 'node_modules/**/*.json', + // Package-owned Markdown includes runtime skill instructions and badge content. 'node_modules/**/*.md', 'node_modules/**/*.dylib', 'node_modules/**/*.dll', @@ -56,6 +57,10 @@ const ASSET_GLOBS = [ 'node_modules/**/*.wasm', 'node_modules/**/*.yaml', 'node_modules/**/*.yml', + // web-app builds this path dynamically, so pkg cannot discover the static frontend. + 'node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*', + // skill-badge resolves both Markdown and image resources through import.meta.url. + 'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*', ] const PLATFORMS = ['linux', 'macos'] as const From d801f262d84b05c3af1f54ef8e9ce173cbd1dd2c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:59:49 +0800 Subject: [PATCH 27/42] fix(python-sdk): resolve packaged proxies from real module entries The packaged dsh launcher must expose installation modules to profile-local plugins without writing symlinks into pkg's virtual filesystem. The first review fix selected ESM exports correctly in ordinary Node, but real carrier execution exposed package metadata and VFS behavior that a synthetic tree did not cover: executable and declaration packages have no import entry, legacy main fields rely on Node probing, and pkg's Windows VFS prevents filesystem package-scope resolution from seeing exports such as zod/mini and @google/genai/web. Resolve explicit exports directly from each installed manifest with the maintained resolve.exports package under Node import conditions. Publish only package-local candidate files that exist, reject escaping or malformed targets, preserve the package installation URL without realpath, and keep Node's legacy resolver only for exports-less packages. This avoids pkg filesystem package lookup entirely while retaining fail-loud behavior for broken runtime entries. Add regression coverage for import-only, nested, symlinked, zod-style, and genai-style condition maps; unavailable and types-only entries; invalid and escaping targets; executable/declaration packages; extensionless main; and legacy index fallback. profile.ts remains at 100% statements, branches, functions, and lines. Update the bilingual package and Agent Note contracts, replace the runtime dependency and generated notice, and regenerate the lockfile through pnpm. --- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/package.json | 4 +- packages/boot/app-boot/src/profile.ts | 69 ++++++--- packages/boot/app-boot/tests/profile.spec.ts | 145 ++++++++++++++++-- pnpm-lock.yaml | 12 +- 11 files changed, 206 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 17e40be1db..158540a66f 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 14c58c85923aac1b54362c72f61f7f1e1c145124 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 9f584ca9bfcc7907caede70ef7e9781f2bc37aab +2026-08-23-python-sdk-dsh-profile-runtime.md: 74484f46b59c3395d7807ae6819026ad8793d033 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 0bc9b65115a18411b83bec4e791c2ddff41e76e1 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 14c58c8592..74484f46b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -30,7 +30,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. -Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit runtime exports under ESM import conditions and re-exports its virtual module URLs. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 9f584ca9bf..0bc9b65115 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -30,7 +30,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 -普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理按 ESM import 条件解析源包的显式运行时 exports,并重新导出其虚拟模块 URL。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 347f3b035e..2b2a3019c8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,7 +66,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | -| [`import-meta-resolve`](https://github.com/wooorm/import-meta-resolve) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | | [`katex`](https://github.com/KaTeX/KaTeX) | MIT | | [`koffi`](https://github.com/Koromix/koffi) | MIT | @@ -89,6 +88,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | | [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | +| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 6bd721e291..93f5f1ae4c 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: f48abfa445e4ac604b5e1bc694f8bae395b566e7 -README.zh.md: e903c74fb12fee0fca1d201d217dd28be8b7e08d +README.md: 1fa00eefae367e2a5a44966d2f2debff9f95c074 +README.zh.md: ef57478b8a3de723e4fa8ce88f87d563eb5a81e5 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index f48abfa445..1fa00eefae 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves exports with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index e903c74fb1..ef57478b8a 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则按 Node ESM import 条件解析 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 96594a9117..d33cae8870 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -33,8 +33,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", - "import-meta-resolve": "^4.2.0", - "js-yaml": "^4.2.0" + "js-yaml": "^4.2.0", + "resolve.exports": "^2.0.3" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 3f2a3d11c0..dc7172ea5d 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -24,15 +24,15 @@ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' -import { basename, dirname, join } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { basename, dirname, join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import { resolve as resolveImport } from 'import-meta-resolve' +import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports' import { loadOverlayPatches } from './index.ts' /** Directory under the Harness home holding every profile. */ @@ -276,27 +276,44 @@ function isPackagedExecutable(): boolean { return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined } -/** Resolve one package export with the ESM conditions used by the generated proxy. */ -function packageEntryFromPackage(packageName: string, packageDir: string, specifier: string): string { +/** Resolve one available explicit package export under Node ESM import conditions. */ +function packageEntryFromPackage( + packageName: string, + packageDir: string, + declared: ResolvePackageManifest['exports'], + subpath: string, +): string | undefined { + let candidates: string[] | void try { - const resolved = resolveImport(specifier, pathToFileURL(join(packageDir, 'package.json')).href) - if (!resolved.startsWith('file:') || !existsSync(fileURLToPath(resolved))) { - throw new Error(`resolved to missing or non-file URL ${resolved}`) - } - return resolved + candidates = resolvePackage({ name: packageName, exports: declared }, subpath) } catch (error) { + if ((error as Error).message.startsWith('No known conditions for ')) return undefined + const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) throw new Error(`dsh: cannot resolve ESM export ${specifier} from installed package ${packageName}`, { cause: error }) } + for (const candidate of candidates ?? []) { + const target = candidate + const entry = resolve(packageDir, target) + const relativeEntry = relative(packageDir, entry) + if (!target.startsWith('./') || /^\.\.(?:[\\/]|$)/u.test(relativeEntry)) { + throw new Error(`dsh: installed package ${packageName} export ${subpath} resolves outside its package: ${target}`) + } + if (existsSync(entry) && statSync(entry).isFile()) return pathToFileURL(entry).href + } + return undefined } -/** Resolve every explicit runtime export that an out-of-tree plugin can import. */ +/** Resolve every explicit ESM runtime export that an out-of-tree plugin can import. */ function packageProxySource( packageName: string, packageDir: string, ): { version: string; targets: Record } { const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + bin?: unknown exports?: unknown main?: unknown + types?: unknown + typings?: unknown version?: unknown } if (typeof manifest.version !== 'string' || manifest.version.length === 0) { @@ -304,20 +321,34 @@ function packageProxySource( } const declared = manifest.exports if (declared === undefined) { - const entry = join(packageDir, typeof manifest.main === 'string' ? manifest.main : 'index.js') - if (!existsSync(entry)) { - throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`) + const main = typeof manifest.main === 'string' && manifest.main.length > 0 ? manifest.main : undefined + const entry = join(packageDir, main ?? 'index') + try { + const resolved = createRequire(join(packageDir, 'package.json')).resolve(entry) + return { version: manifest.version, targets: { '.': pathToFileURL(resolved).href } } + } catch (error) { + if (main === undefined + && (manifest.bin !== undefined || manifest.types !== undefined || manifest.typings !== undefined)) { + return { version: manifest.version, targets: {} } + } + throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`, { cause: error }) } - return { version: manifest.version, targets: { '.': pathToFileURL(entry).href } } } const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared) && Object.keys(declared).some(key => key.startsWith('.')) - ? Object.keys(declared).filter(key => key === '.' || (key.startsWith('./') && !key.includes('*') && key !== './package.json')) + ? Object.keys(declared).filter(key => key === '.' || ( + key.startsWith('./') && !key.includes('*') && !key.endsWith('/') && key !== './package.json' + )) : ['.'] const targets: Record = {} for (const subpath of subpaths) { - const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) - targets[subpath] = packageEntryFromPackage(packageName, packageDir, specifier) + const target = packageEntryFromPackage( + packageName, + packageDir, + declared as ResolvePackageManifest['exports'], + subpath, + ) + if (target !== undefined) targets[subpath] = target } return { version: manifest.version, targets } } diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 6eb37bc65a..552bf79174 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -359,7 +359,12 @@ describe('healProfilesModuleFallback', () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record - bundleManifest.exports = { '.': './index.js', './feature': './feature.js' } + bundleManifest.exports = { + '.': './index.js', + './feature': './feature.js', + './legacy/': './legacy/', + './types': { types: './feature.d.ts' }, + } writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) writeFileSync(join(bundleDir, 'feature.js'), 'export const feature = "proxied"\n') const home = tmp() @@ -416,6 +421,61 @@ describe('healProfilesModuleFallback', () => { } }) + it('resolves explicit condition targets without filesystem package lookup', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.exports = { + '.': { import: './index.js', require: './index.cjs' }, + './mini': { types: './mini/index.d.ts', import: './mini/index.js', require: './mini/index.cjs' }, + './web': { types: './dist/web/web.d.ts', import: './dist/web/index.mjs', default: './dist/web/index.mjs' }, + } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + mkdirSync(join(bundleDir, 'mini')) + writeFileSync(join(bundleDir, 'mini', 'index.js'), 'export const mini = true\n') + mkdirSync(join(bundleDir, 'dist', 'web'), { recursive: true }) + writeFileSync(join(bundleDir, 'dist', 'web', 'index.mjs'), 'export const web = true\n') + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ mini: true }) + await expect(import(join(proxy, 'entry-2.js'))).resolves.toMatchObject({ web: true }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('preserves the installation path while resolving packaged exports', async () => { + const anchor = stageInstallation({}) + const appDir = join(anchor, '..') + const physical = tmp() + writeFileSync(join(physical, 'package.json'), JSON.stringify({ + name: 'linked-esm', + version: '0.0.0', + type: 'module', + exports: { import: './index.js' }, + })) + writeFileSync(join(physical, 'index.js'), 'export const linked = true\n') + symlinkSync(physical, join(appDir, 'node_modules', 'linked-esm'), 'junction') + const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record } + appManifest.dependencies['linked-esm'] = '0.0.0' + writeFileSync(anchor, JSON.stringify(appManifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + const proxyManifest = JSON.parse(readFileSync( + join(home, 'profiles', 'node_modules', 'linked-esm', 'package.json'), + 'utf8', + )) as { dsh: { moduleFallback: { targets: Record } } } + expect(proxyManifest.dsh.moduleFallback.targets['.']).toContain('/app/node_modules/linked-esm/index.js') + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + it('uses the legacy index fallback when a package has no exports or main', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') @@ -433,20 +493,87 @@ describe('healProfilesModuleFallback', () => { } }) - it('fails loud on a missing legacy main entry or ESM export target', async () => { - for (const mode of ['legacy', 'exports']) { + it('uses Node legacy resolution for an extensionless main entry', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.main = './index' + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) + .resolves.toMatchObject({ packageName: 'bundle-a' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('skips executable-only and declaration-only packages without import entries', async () => { + for (const marker of ['bin', 'types', 'typings']) { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const manifest = JSON.parse(readFileSync(anchor, 'utf8')) as Record + delete manifest.main + manifest[marker] = marker === 'bin' ? { dsh: './lib/bin.js' } : './index.d.ts' + if (marker === 'types') manifest.main = '' + writeFileSync(anchor, JSON.stringify(manifest)) + rmSync(join(anchor, '..', 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback(anchor, home) + const fallback = join(home, 'profiles', 'node_modules') + expect(existsSync(join(fallback, 'dsh-app'))).toBe(false) + expect(existsSync(join(fallback, 'bundle-a', 'entry-0.js'))).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + } + }) + + it('fails loud on a missing legacy main entry', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + delete manifest.main + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + rmSync(join(bundleDir, 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow('main entry is missing') + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('omits unavailable ESM exports and rejects malformed export targets', async () => { + for (const mode of ['missing', 'directory', 'absent-map', 'invalid', 'escape', 'null', 'null-subpath']) { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record - if (mode === 'legacy') delete manifest.main - else manifest.exports = { '.': { import: './missing.js' } } + const target = mode === 'missing' ? './missing.js' + : mode === 'directory' ? './mini' + : mode === 'escape' ? './../outside.js' + : '../outside.js' + manifest.exports = mode === 'absent-map' ? null + : mode === 'null-subpath' ? { './bad': null } + : { '.': mode === 'null' ? null : { import: target } } writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) - rmSync(join(bundleDir, 'index.js')) + if (mode === 'directory') mkdirSync(join(bundleDir, 'mini')) Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) try { - await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow( - mode === 'legacy' ? 'main entry is missing' : 'cannot resolve ESM export bundle-a', - ) + const home = tmp() + if (mode === 'missing' || mode === 'directory' || mode === 'absent-map') { + await healProfilesModuleFallback(anchor, home) + expect(existsSync(join(home, 'profiles', 'node_modules', 'bundle-a'))).toBe(false) + } else { + await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow( + mode === 'null' || mode === 'null-subpath' + ? 'cannot resolve ESM export bundle-a' + : 'resolves outside its package', + ) + } } finally { delete (process as NodeJS.Process & { pkg?: unknown }).pkg } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7c4e7d7f1..daec43dbff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1148,12 +1148,12 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write - import-meta-resolve: - specifier: ^4.2.0 - version: 4.2.0 js-yaml: specifier: ^4.2.0 version: 4.2.0 + resolve.exports: + specifier: ^2.0.3 + version: 2.0.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -14965,6 +14965,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -20607,6 +20611,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} + retry@0.13.1: {} rfdc@1.4.1: {} From c2ad69344f288034f19d246d73ea808b37b80724 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:10:12 +0800 Subject: [PATCH 28/42] fix(python-sdk): make the minimal profile an explicit allowlist Give the SDK JSON-RPC server a per-root tool filter and let deployments mark the configured persona as the complete system prompt. The checked-in minimal overlay now names only bash and str_replace_editor, so later global tools and unrelated guidance from dsh-base cannot appear implicitly. Keep the shared SDK host services and packaged Web capability intact. Only workspace instructions, compaction, and the conflicting one-shot Bash row remain disabled. Unit coverage pins the configuration paths, and a real dsh profile smoke proves the assembled prompt and exact two-tool request. --- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 + ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 20 +++- docs/config-catalog.zh.md | 22 ++++- docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 2 +- docs/user/guide/python-sdk.zh.md | 2 +- examples/python-sdk-agent/README.i18n.yaml | 4 +- examples/python-sdk-agent/README.md | 4 +- examples/python-sdk-agent/README.zh.md | 4 +- examples/python-sdk-agent/minimal.patch.yml | 49 +++------- .../tests/keyless-smoke.e2e.ts | 92 +++++++++++++++++++ packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 1 + packages/core/system-prompt/README.zh.md | 1 + packages/core/system-prompt/src/index.ts | 4 + .../system-prompt/tests/system-prompt.spec.ts | 15 +++ .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 4 +- .../examples/agent-spine-demo/README.zh.md | 4 +- .../examples/agent-spine-demo/src/index.ts | 13 ++- .../agent-spine-demo/tests/agent-core.spec.ts | 4 + packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 4 +- packages/sdk/server/README.zh.md | 4 +- packages/sdk/server/src/index.ts | 13 +++ packages/sdk/server/src/server.ts | 7 ++ .../sdk/server/tests/plugin-apply.spec.ts | 54 ++++++++++- packages/sdk/server/tests/server.spec.ts | 41 +++++++++ scripts/smoke-python-runtime.py | 24 ++--- 32 files changed, 327 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 158540a66f..2082ebf627 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 74484f46b59c3395d7807ae6819026ad8793d033 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 0bc9b65115a18411b83bec4e791c2ddff41e76e1 +2026-08-23-python-sdk-dsh-profile-runtime.md: 19c870b3b0e10b25480bacc85b9db29b01d2577d +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 404798ff4ae1fcafbaa8403c7187297adf374a19 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 74484f46b5..19c870b3b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -24,6 +24,8 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. +The checked-in minimal overlay retains the shared SDK profile while configuring the server with a root-agent tool allow list. Later base-bundle tools remain unavailable unless the overlay names them. Its deployment persona is a complete system prompt, so unrelated guidance sections cannot describe hidden tools; dynamic runtime context, workspace instructions, compaction, and the conflicting one-shot Bash row are disabled separately. + The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. ### Executable packaging diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 0bc9b65115..404798ff4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -24,6 +24,8 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 +检入的极简 overlay 会保留共享 SDK profile,并为 server 配置根 agent 工具 allow 列表。基础 bundle 后续新增的工具只有在 overlay 指名时才可用。其部署 persona 是完整系统提示词,因此无关引导段不会描述已隐藏的工具;动态运行时上下文、workspace 指令、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。 + 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 ### 可执行程序打包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9dc39c909c..4998dcd505 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: acd32b7e76180f13956ec8a54ca16d0ef99bf203 -config-catalog.zh.md: eacca1575191cff6dde93cf80e5ac576e47f485c +config-catalog.md: cb156837fa0768dfa74d9b351cc3e362449ca248 +config-catalog.zh.md: 151a72190332a400b643111495d36e2d7f205d93 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index acd32b7e76..cb156837fa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -166,9 +166,10 @@ Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/ag * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -196,6 +197,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -247,7 +250,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -1689,6 +1692,13 @@ Requires: `agents` export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -2414,6 +2424,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eacca15751..151a721903 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -168,9 +168,10 @@ export type PresetTrust = 'system' | 'user' * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -198,6 +199,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -249,7 +252,7 @@ export interface GoalConfig { 依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -1691,6 +1694,13 @@ export interface Config { export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -1702,7 +1712,7 @@ export interface JsonRpcConfig { 依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`) -来源:[`packages/sdk/server/src/index.ts:29`](../packages/sdk/server/src/index.ts) +来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts) @@ -2416,6 +2426,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index bbb6cff7ec..69e9720f11 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md -python-sdk.md: 4fbc6b343714b232b90a7954b35bf6863716281b -python-sdk.zh.md: 51637ed986d298d46633891077bf2a9d27c35bdd +python-sdk.md: 98d7f8160dee0874338c1d914796dcfb72400a48 +python-sdk.zh.md: 8a0417b24be859b8d327a8650ce1db23adb4dba2 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 4fbc6b3437..98d7f8160d 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -101,7 +101,7 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe | Context compaction | Disabled | | Session persistence | Zstandard JSONL under `/sessions` | -The overlay removes runtime-context prompt messages and most default tools while retaining the SDK application's protocol and persistence. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. +The overlay allowlists persistent Bash and the editor for every SDK-created root agent, so later base-profile tools cannot appear implicitly. It suppresses unrelated prompt sections and runtime-context messages, disables local instruction discovery and compaction, and retains the SDK application's protocol, persistence, policy, settings, credentials, and providers. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. Use a fresh home when profiles, plugins, credentials, settings, and sessions must be isolated. Use a fresh session id for independent work; reuse a harness, home, and id only to continue the same durable conversation and session-owned resources. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 51637ed986..8a0417b24b 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -101,7 +101,7 @@ dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle | 上下文压缩 | 禁用 | | 会话持久化 | `/sessions` 下的 Zstandard JSONL | -该 overlay 会移除运行时上下文提示消息与大多数默认工具,但保留 SDK 应用的协议与持久化。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 +该 overlay 会为每个由 SDK 创建的根 agent allowlist 持久 Bash 与 editor,因此基础 profile 以后新增的工具不会隐式出现。它会抑制无关提示词段与运行时上下文消息,停用本地指令发现与 compaction,并保留 SDK 应用的协议、持久化、策略、settings、credentials 与 provider。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 需要隔离 profile、插件、凭据、设置与会话时,应使用新的 home。独立工作应使用新的 session id;只有继续同一段持久对话和会话资源时,才同时复用 harness、home 与 id。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index 1f9b897250..f0f1ef1980 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-agent/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 examples/python-sdk-agent/README.md -README.md: e46765b0419b0b614163fa507cadef0e5c5a53b9 -README.zh.md: 13c297bdbf01b612369ded5909ec8b7f71bab26c +README.md: b1718bfa3fb6ea4cb241bb4211b58a21bacc17a2 +README.zh.md: dbc4053093f66a60a18ab02bb727611a279fe03c diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index e46765b041..b1718bfa3f 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -19,12 +19,12 @@ python examples/python-sdk-agent/minimal.py \ Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` and `--profile` override their script defaults. The selected home stores the generated profile and Zstandard session logs under `sessions/`; the script never reads `~/.dsh` implicitly. -[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. It preserves the SDK application bundle but narrows model-visible behavior to: +[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. Its root-agent tool allowlist exposes exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -The patch omits Harness identity and runtime-context messages, local instruction discovery, skills, compaction, plan/goal/task/web/subagent/workflow tools, and the profile's one-shot Bash. It inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. +The allowlist excludes every other current or later global tool without requiring a disable entry for each base row. A complete deployment persona suppresses unrelated tool-guidance sections; runtime-context messages, local instruction discovery, compaction, and the conflicting one-shot Bash row are disabled separately. The overlay inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. Other SDK-profile services remain mounted, including persistence, policy, settings, credentials, and providers. This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index 13c297bdbf..dbc4053093 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -19,12 +19,12 @@ python examples/python-sdk-agent/minimal.py \ 兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 与 `--profile` 会覆盖脚本默认值。所选 home 保存生成的 profile,并在 `sessions/` 下保存 Zstandard 会话日志;脚本绝不会隐式读取 `~/.dsh`。 -[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。它保留 SDK 应用 bundle,但将模型可见行为收窄为: +[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。其根 agent 工具 allow 列表只暴露: - agent 所有的持久 `bash` - 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -该 patch 会省略 Harness 身份与运行时上下文消息、本地指令发现、skill、compaction,以及 plan/goal/task/web/subagent/workflow 工具和 profile 的单次 Bash。它插入本地 PTY 与持久 Bash provider,并把 editor 输出上限设为 16,000 字符。 +Allow 列表会排除当前及以后出现的其他所有全局工具,无需为每个基础配置项添加 disable。完整部署 persona 会抑制无关工具的引导段;运行时上下文消息、本地指令发现、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。该 overlay 会插入本地 PTY 与持久 Bash provider,并将 editor 输出上限设为 16,000 字符。其他 SDK profile 服务仍保持挂载,包括持久化、策略、settings、credentials 与 provider。 此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 diff --git a/examples/python-sdk-agent/minimal.patch.yml b/examples/python-sdk-agent/minimal.patch.yml index be3f292cc0..2c3ac0f5bc 100644 --- a/examples/python-sdk-agent/minimal.patch.yml +++ b/examples/python-sdk-agent/minimal.patch.yml @@ -1,49 +1,28 @@ -# Minimal Python SDK overlay for `dsh --profile sdk`: keep only persistent -# Bash and the string-replacement editor, with no runtime-context prompt or -# compaction. The profile still owns JSON-RPC serving and persistence. +# Minimal Python SDK overlay for `dsh --profile sdk`. The SDK server applies +# the model-facing allowlist to every root session, so later base-bundle tools +# cannot appear implicitly. The profile still owns JSON-RPC and persistence. - id: system-prompt config: includeHarnessIdentity: false includeRuntimeContext: false persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + personaComplete: true +- id: sdk-jsonrpc-server + config: + maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" + toolFilter: + allow: + - bash + - str_replace_editor + +# Remove non-tool model inputs and turn rewriting, plus the one-shot Bash row +# that would conflict with the persistent Bash tool inserted below. - id: agent-instructions disabled: true -- id: skill-filesystem - disabled: true -- id: tool-skill - disabled: true - id: tool-bash disabled: true -- id: tool-jobs - disabled: true -- id: tool-fs - disabled: true -- id: tool-fs-search - disabled: true -- id: tool-subagent-control - disabled: true -- id: tool-subagent-list-agents - disabled: true -- id: tool-subagent - disabled: true -- id: tool-subagent-fork - disabled: true -- id: tool-subagent-report - disabled: true -- id: tool-workflow - disabled: true -- id: tool-todo - disabled: true -- id: tool-goal - disabled: true -- id: tool-ralph - disabled: true -- id: tool-web - disabled: true -- id: plan-mode - disabled: true - id: compaction-basic disabled: true - id: command-compact diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index c0aabb48b8..4b2519c7f8 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) +const minimalPatchPath = fileURLToPath(new URL('../minimal.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -180,6 +181,97 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) + it('keeps the minimal overlay on an explicit model-facing allowlist', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) + const modelRequests: Record[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + modelRequests.push(JSON.parse(body) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.end('data: [DONE]\n\n') + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') + const child = execa(process.execPath, [ + '--import', + 'tsx/esm', + binScript, + '--profile', + 'sdk', + '--patch', + minimalPatchPath, + ], { + cwd: repoRoot, + env: { + DSH_HOME: join(root, '.dsh'), + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + }, + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, + }) + const lines: string[] = [] + let stdoutBuffer = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') + const parts = stdoutBuffer.split('\n') + stdoutBuffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + + try { + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + })}\n`) + await waitForLine(lines, value => value.id === 1, () => stderr) + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + })}\n`) + await waitForLine(lines, (value) => { + const params = value.params as Record | undefined + const event = params?.event as Record | undefined + return params?.sessionId === 'minimal' && event?.type === 'turn/end' + }, () => stderr) + + const request = modelRequests[0] as { + messages?: Array<{ role?: string; content?: unknown }> + tools?: Array<{ function?: { name?: string } }> + } + expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) + expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor']) + + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) + await waitForLine(lines, value => value.id === 3, () => stderr) + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) + } finally { + child.kill('SIGKILL') + await child + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(root, { recursive: true, force: true }) + } + }, 40_000) + it('rejects an invalid max-token success env value', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-')) try { diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index dd68b6a139..c34ff9547a 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: d750a507e628e7609af542227e4528d4d4934ce8 -README.zh.md: ec5b32d742b96c8044a3707c35f15e30ba642b4f +README.md: a52aa3e4c2782993fed5a525cc827aba4e3eaeb0 +README.zh.md: cf0ba43aa2f2f47ea61ef13c74fbf182fbd9f2ee diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index d750a507e6..a52aa3e4c2 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -11,6 +11,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `includeRuntimeContext` | `true` | Include ordered dynamic contexts in assembly. When false, context providers are not evaluated and contexts added by `system-prompt/assemble` listeners are discarded after the waterfall; other services and their enforcement remain active. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | +| `personaComplete` | `false` | Treat `persona` as the complete system prompt after assembly. Other sections remain registered but are omitted from model requests; tool schemas and variables remain available. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index ec5b32d742..cf0ba43aa2 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -11,6 +11,7 @@ | `includeHarnessIdentity` | `true` | 是否包含顺序为 −100 的固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 | | `includeRuntimeContext` | `true` | 是否在组装中包含有序动态上下文。设为 false 时不会求值上下文提供方,并会在 waterfall 后丢弃 `system-prompt/assemble` 监听器添加的上下文;其他服务及其强制机制仍然生效。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置提供的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | +| `personaComplete` | `false` | 在组装后将 `persona` 作为完整系统提示词。其他段仍保持注册,但不会进入模型请求;工具 schema 与变量仍然可用。 | | `toolOrder` | 无 | 显式指定面向模型的工具顺序。该列表由 `ToolSchema.name` 组成,并且必须恰好包含一个 `''` 其余项标记(`TOOL_ORDER_REST`):已列工具按列表位置排列,未列工具则按名称字典序插入该标记所在的位置。缺席 ⇒ 直接按名称字典序排列。该顺序会在 `system-prompt/assemble` waterfall(瀑布式事件)之前应用于已收集的工具。与段的 `order` 排序一样,它会规范化注册表贡献的内容;注册顺序只是插件加载时序的产物。修改列表的 waterfall 监听器对其输出的确定性负责。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在随附循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md)。 | ## 服务:`SystemPrompt`(ctx 键:`systemPrompt`) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index ffc052e0b9..ec36b32432 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -193,6 +193,8 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string + /** Treat the deployment persona as the complete system prompt (default false). */ + personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names @@ -340,6 +342,7 @@ export class SystemPrompt extends Service { includeHarnessIdentity: z.boolean().default(true), includeRuntimeContext: z.boolean().default(true), persona: z.string().default(''), + personaComplete: z.boolean().default(false), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -366,6 +369,7 @@ export class SystemPrompt extends Service { order: PERSONA_ORDER, // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', + complete: config.personaComplete ?? false, }) if (!(config.includeRuntimeContext ?? true)) this.suppressRuntimeContext() } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index cf196892a7..c4018103d3 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -49,6 +49,21 @@ describe('SystemPrompt', () => { expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') }) + it('can make the deployment persona the complete system prompt', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { + persona: 'You are a focused SDK agent.', + personaComplete: true, + }) + ctx.systemPrompt.section({ name: 'tool:future', order: 100, text: 'Future tool guidance.' }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections).toEqual([ + { name: 'deployment:persona', text: 'You are a focused SDK agent.' }, + ]) + expect(renderPrompt(assembly)).toBe('You are a focused SDK agent.') + }) + it('can suppress runtime context without evaluating providers or accepting waterfall additions', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, { includeRuntimeContext: false }) diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 1144fb7047..dedce4969d 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 509e4e6d6b5885fb0c685e4ee34fe8078944415b -README.zh.md: 6b429d139a40daa516735b1192af8924e8c9f7cd +README.md: 28e1496a8c941f6524ec4b4dfbfad52d17df5d71 +README.zh.md: c2838bb4cf78d6ac863bac2eba4d2e4df335fa55 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 509e4e6d6b..28e1496a8c 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service Provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` is forwarded to `dsh-system-prompt` and suppresses all dynamic context snapshots for fresh sessions without disabling their policy services. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services; `personaComplete: true` makes the deployment persona the sole system-prompt section. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../runtime-diagnostics/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 6b429d139a..c2838bb4cf 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会转发给 `dsh-system-prompt`,为新建会话抑制所有动态上下文快照,但不禁用其策略服务。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务;`personaComplete: true` 会让部署 persona 成为唯一系统提示词段。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../runtime-diagnostics/invariants/README.zh.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 87098c38da..a60ebd9e43 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -70,9 +70,10 @@ export interface GoalConfig { * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, - * dynamic-context policy, deployment persona, and explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin + * (the fixed opener, dynamic-context policy, deployment persona completeness, + * and explicit model-facing tool order), the `tools` object to the tool + * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -100,6 +101,8 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] + /** Whether the deployment persona is the complete system prompt. */ + personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -185,6 +188,7 @@ export function pickSpineConfig(config: Omit): Omit): Omit { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are a helpful software engineer assistant.', + personaComplete: true, workspaceContext: false, skills: { enabled: false }, toolBash: false, @@ -723,6 +724,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(ctx.tools.schemas()).toEqual([]) ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'hidden policy' }) expect((await ctx.systemPrompt.assemble()).contexts).toEqual([]) + ctx.systemPrompt.section({ name: 'hidden', order: 100, text: 'hidden guidance' }) expect(renderPrompt(await ctx.systemPrompt.assemble())) .toBe('You are a helpful software engineer assistant.') @@ -736,6 +738,7 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are merged.', + personaComplete: true, toolOrder: ['zulu'], tools: { mode: 'native' as const }, dshHome: '/tmp/dsh-home', @@ -754,6 +757,7 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: appConfig.includeHarnessIdentity, includeRuntimeContext: appConfig.includeRuntimeContext, persona: appConfig.persona, + personaComplete: appConfig.personaComplete, toolOrder: appConfig.toolOrder, tools: appConfig.tools, dshHome: appConfig.dshHome, diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 5301b4b19e..3ffdb5d1e4 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/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/sdk/server/README.md -README.md: 547836ddab99d5c5a5c39c013c38b4bb25ba7e7d -README.zh.md: 97a7e9897fbf338a79c2cb33a8bab809c1cd3899 +README.md: 2fac60b9313c66a8eb1653adb02405f2f5fe4b08 +README.zh.md: ed2c18f0a2ee96fdacdf0d998314d349f0b0264b diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 547836ddab..2fac60b931 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. Optional `toolFilter.allow` and `toolFilter.deny` restrict each SDK-created root agent through `ctx.tools.restrict()`. An allow list excludes later global tool registrations that it does not name, so a fixed SDK deployment cannot silently gain model-facing tools when its base bundle expands. Unknown names and an empty filter fail when the first session is created. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. ## stdout is the protocol @@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s #### What the model sees -For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. +For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. A configured `toolFilter` projects that composition's global tool registry before the request is assembled and executed. #### Token effect diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 97a7e9897f..ed2c18f0a2 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -10,7 +10,7 @@ ## 配置 -`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 +`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。可选的 `toolFilter.allow` 与 `toolFilter.deny` 通过 `ctx.tools.restrict()` 限制每个由 SDK 创建的根 agent。Allow 列表会排除之后出现但未指名的全局工具,因此固定的 SDK 部署不会在基础 bundle 扩展时静默获得面向模型的新工具。未知名称与空筛选器会在创建首个会话时明确失败。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 ## stdout 即协议 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 +对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样作为该 SDK 会话中的一条用户消息接收。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。配置的 `toolFilter` 会在请求组装与执行前投影该组合的全局工具注册表。 #### Token 影响 diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 963b4fb3bd..17e9d3a89f 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -25,6 +25,13 @@ export const inject = ['agents'] export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean + /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ + toolFilter?: { + /** Global tool names that remain visible. */ + allow?: string[] + /** Global tool names removed from visibility. */ + deny?: string[] + } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -35,6 +42,11 @@ export interface JsonRpcConfig { export const Config: Schema = Schema.object({ maxTokensAsSuccess: Schema.boolean().default(false), + // Preserve omission; Schemastery's materialized empty object is not a valid restriction. + toolFilter: Schema.object({ + allow: Schema.array(Schema.string()).default(undefined as unknown as string[]), + deny: Schema.array(Schema.string()).default(undefined as unknown as string[]), + }).default(undefined as unknown as { allow: string[]; deny: string[] }), }) /** @@ -59,6 +71,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkJsonRpcServer(ctx, transport, { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, + ...resolvedConfig.toolFilter === undefined ? {} : { toolFilter: resolvedConfig.toolFilter }, }) // Share one exit task so racing shutdown requests cannot dispose the root or diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index ccc3b6192d..a2ec97d9f4 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -13,6 +13,7 @@ import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { InitializeParams, @@ -38,6 +39,8 @@ function subagentParentOf(carrier: Scoped): Agent { export interface HarnessSdkJsonRpcServerOptions { /** Report max-token termination as an accepted result instead of an infrastructure error. */ maxTokensAsSuccess?: boolean + /** Restrict each SDK-created root agent to an explicit subset of global tools. */ + toolFilter?: ToolRestriction } function successStatus(reason: string, options: HarnessSdkJsonRpcServerOptions): 'ok' | 'error' { @@ -220,6 +223,7 @@ export class HarnessSdkJsonRpcServer { // rows in the host plane, so this agent reads them from the global layer. A // deployment that configures a roster has to join one here first // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). + const toolFilter = this.options.toolFilter const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, @@ -228,6 +232,9 @@ export class HarnessSdkJsonRpcServer { model: this.model, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, + ...toolFilter === undefined + ? {} + : { setup: (agentCtx: Context) => { agentCtx.tools.restrict(toolFilter) } }, }) const rec: SessionRecord = { handle } this.sessions.set(sessionId, rec) diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 28c89c19fe..41954007eb 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -11,6 +11,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import { defineTool } from '@deepseek-ai/dsh-tools' import * as jsonrpc from '../src/index.ts' /** @@ -71,6 +72,7 @@ async function mountPlugin( writeDelayMs?: number failFlush?: boolean beforeServer?: (ctx: Context) => Promise | void + toolFilter?: jsonrpc.JsonRpcConfig['toolFilter'] } = {}, ): Promise { const ctx = new Context() @@ -116,7 +118,12 @@ async function mountPlugin( const exit = (code: number): void => { events.push({ kind: 'exit', code }) } ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness') - const fiber = await ctx.plugin(jsonrpc, { input, output, exit }) + const fiber = await ctx.plugin(jsonrpc, { + input, + output, + exit, + ...options.toolFilter === undefined ? {} : { toolFilter: options.toolFilter }, + }) const frames = (): Record[] => events.flatMap(event => event.kind === 'frame' ? [event.frame] : []) @@ -282,6 +289,51 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => { } }) + it('applies the configured root-agent tool filter through the Loader plugin', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-tool-filter-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const harness = await mountPlugin(storageDir, { + toolFilter: { allow: ['kept'] }, + beforeServer: (ctx) => { + for (const name of ['kept', 'excluded']) { + ctx.tools.register(defineTool({ + name, + description: name, + parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: async () => name, + })) + } + }, + }) + try { + harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' } }) + await harness.waitForFrame(frame => frame.id === 1, 'initialize response') + harness.send({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'filtered', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + }) + await harness.waitForFrame( + frame => frame.method === 'session.status' + && (frame.params as { status?: string } | undefined)?.status === 'idle', + 'filtered session idle status', + ) + + const request = llmServer.requests[0] as { tools?: Array<{ function?: { name?: string } }> } + expect(request.tools?.map(entry => entry.function?.name)).toEqual(['kept']) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 495f1d90f4..d9527e10d5 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -14,6 +14,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { defineTool } from '@deepseek-ai/dsh-tools' import { HarnessSdkJsonRpcServer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -171,6 +172,46 @@ describe('HarnessSdkJsonRpcServer', () => { } }) + it('allowlists each root session against current and later global tools', { timeout: 15_000 }, async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-tool-filter-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const ctx = await makeHarness(storageDir) + const tool = (name: string) => defineTool({ + name, + description: name, + parameters: {}, + output: { + schema: { type: 'string' as const }, + render: (_args, value) => [{ type: 'text' as const, text: value }], + }, + execute: async () => name, + }) + ctx.tools.register(tool('kept')) + ctx.tools.register(tool('excluded')) + const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport(), { + toolFilter: { allow: ['kept'] }, + }) + try { + await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' }) + await server.prompt({ sessionId: 'first', contentBlocks: [{ type: 'text', text: 'first' }] }) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) + ctx.tools.register(tool('future')) + await server.prompt({ sessionId: 'second', contentBlocks: [{ type: 'text', text: 'second' }] }) + await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) }) + + expect(llmServer.requests.map((request) => { + const tools = (request as { tools?: Array<{ function?: { name?: string } }> }).tools ?? [] + return tools.map(entry => entry.function?.name) + })).toEqual([['kept'], ['kept']]) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('queues overlapping prompts for one session without blocking other sessions', async () => { const mainFollowup = vi.fn() const mainAgent = ({ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 06653c5741..3be8a9bcfb 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -985,23 +985,7 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - sessions = dsh_home / "sessions" disabled = [ "agent-instructions", - "skill-filesystem", - "tool-skill", "tool-bash", - "tool-jobs", - "tool-fs", - "tool-fs-search", - "tool-subagent-control", - "tool-subagent-list-agents", - "tool-subagent", - "tool-subagent-fork", - "tool-subagent-report", - "tool-workflow", - "tool-todo", - "tool-goal", - "tool-ralph", - "tool-web", - "plan-mode", "compaction-basic", "command-compact", "tool-result-pruner", @@ -1013,6 +997,14 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - "includeHarnessIdentity": False, "includeRuntimeContext": False, "persona": "You are a helpful software engineer assistant.", + "personaComplete": True, + }, + }, + { + "id": "sdk-jsonrpc-server", + "config": { + "maxTokensAsSuccess": True, + "toolFilter": {"allow": ["bash", "str_replace_editor"]}, }, }, *({"id": row_id, "disabled": True} for row_id in disabled), From ab4e65ba82a48e6bc298ed4f32788cbade3a23ad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:11:05 +0800 Subject: [PATCH 29/42] perf(app-boot): avoid fallback locks for complete profiles Resolve the installation fallback generation before locking and return immediately when every required symlink or packaged proxy is complete. Parallel SDK rollouts sharing an initialized DSH_HOME therefore do not queue on profiles/node_modules.lock. Missing or stale entries still acquire the cross-process writer lock, recheck the generation, and repair under exclusive ownership. Tests hold the lock to prove the steady-state bypass and verify that a partial repair retains already-correct siblings. --- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 130 ++++++++++++------ packages/boot/app-boot/tests/profile.spec.ts | 41 +++++- 8 files changed, 133 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 2082ebf627..8cc79728eb 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 19c870b3b0e10b25480bacc85b9db29b01d2577d -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 404798ff4ae1fcafbaa8403c7187297adf374a19 +2026-08-23-python-sdk-dsh-profile-runtime.md: e3df2d01e3aef7e6eadaa011d51c9ab87456d35f +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 3d50cac36d469172e91be450cea658f3a4830ccf diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 19c870b3b0..e3df2d01e3 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -32,7 +32,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime--`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged. -Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. +Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. A complete matching generation returns without acquiring the cross-process writer lock. A missing or stale entry acquires the lock, rechecks the generation, and repairs it without exposing partial proxies; either carrier can replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 404798ff4a..3d50cac36d 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -32,7 +32,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime--`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。 -普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 +普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。完整且匹配的 generation 不会获取跨进程写入锁。缺失或过期的配置项会获取该锁、重新检查 generation,并在不暴露半成品代理的前提下修复;任一载体都可以替换另一载体留下的受管配置项。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 93f5f1ae4c..ae60e877c0 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 1fa00eefae367e2a5a44966d2f2debff9f95c074 -README.zh.md: ef57478b8a3de723e4fa8ce88f87d563eb5a81e5 +README.md: 0adcb0ac20516b1eea97792a11471a383a3ab429 +README.zh.md: 1da1725b1e0681f83f2c82b82cb9bbaac8ea90cb diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 1fa00eefae..0adcb0ac20 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. A complete matching generation returns without acquiring the writer lock. A missing or stale entry acquires the cross-process lock, rechecks the full generation, and repairs it without exposing partial proxies; either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index ef57478b8a..1da1725b1e 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。完整且匹配的 generation 不会获取写入锁。缺失或过期的配置项会获取跨进程锁、重新检查完整 generation,并在不暴露半成品代理的前提下修复;两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index dc7172ea5d..17c6cf65ab 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -407,44 +407,12 @@ function ensureModuleProxy( } } -/** - * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one - * entry per package in the dsh app's resolvable dependency CLOSURE (BFS - * over `dependencies` from the app manifest), each resolved from its own - * installation location. Plain Node uses symlinks. A pkg executable resolves - * exports under ESM import conditions and writes small proxy packages because - * the host filesystem cannot follow a symlink into pkg's virtual `/snapshot` - * tree; the proxy re-exports the virtual URL, preserving the executable's - * single module instance. One cross-process writer lock prevents partial - * proxies and serializes carrier transitions. Node's - * parent-directory walk from any profile finds this - * directory after the profile's own `node_modules`, so every in-box plugin - * resolves without pnpm ever managing it — the exact "bundles come from the - * installation" contract. The closure (not just direct dependencies) is - * required for out-of-tree plugins: their peer dependencies name Service - * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app - * reaches only through its Service Provider packages. Both a symlink target - * and a proxy's virtual target resolve transitive imports from the original - * package directory, so each package needs one flat fallback entry. - * Idempotent: correct entries are kept and changed installation targets are - * rewritten; under plain Node, a stale dangling link stays until its name is - * reused because resolution cannot discover it. - * @param installAnchor - absolute path of the dsh app's package.json. - * @param home - the Harness home; defaults to {@link resolveDshHome}. - * @returns settlement after the locked fallback generation is complete. - */ -export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise { - const profilesDir = join(home, PROFILES_DIR) - const modulesDir = join(profilesDir, 'node_modules') - mkdirSync(modulesDir, { recursive: true }) - await withFileLock(modulesDir, () => { - healProfilesModuleFallbackLocked(installAnchor, modulesDir) - return Promise.resolve() - }) -} +type ModuleFallbackEntry = + | { kind: 'symlink'; packageName: string; packageDir: string } + | { kind: 'proxy'; packageName: string; version: string; targets: Record } -/** Heal one module-fallback generation while the cross-process writer lock is held. */ -function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: string): void { +/** Resolve the installation generation that every profile must find through the fallback directory. */ +function resolveModuleFallbackEntries(installAnchor: string): ModuleFallbackEntry[] { const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest const links = new Map() /* v8 ignore next -- a real app manifest always declares its name */ @@ -468,16 +436,88 @@ function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: str queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) } } - for (const [packageName, target] of links) { - const link = join(modulesDir, packageName) + if (!isPackagedExecutable()) { + return [...links].map(([packageName, packageDir]) => ({ kind: 'symlink', packageName, packageDir })) + } + return [...links].flatMap(([packageName, packageDir]) => { + const source = packageProxySource(packageName, packageDir) + return Object.keys(source.targets).length === 0 + ? [] + : [{ kind: 'proxy' as const, packageName, version: source.version, targets: source.targets }] + }) +} + +/** Return whether one existing fallback entry already matches its resolved installation generation. */ +function moduleFallbackEntryCurrent(modulesDir: string, entry: ModuleFallbackEntry): boolean { + const link = join(modulesDir, entry.packageName) + try { + const stat = lstatSync(link) + if (entry.kind === 'symlink') { + return stat.isSymbolicLink() && readlinkSync(link) === entry.packageDir + } + if (!stat.isDirectory()) return false + const existing = readModuleProxyRecord(link) + return existing?.version === entry.version + && JSON.stringify(existing.dsh?.moduleFallback?.targets) === JSON.stringify(entry.targets) + && Object.keys(entry.targets).every((_, index) => existsSync(join(link, `entry-${index}.js`))) + } catch { + return false + } +} + +/** Return whether every required fallback entry is already ready for this installation. */ +function moduleFallbackCurrent(modulesDir: string, entries: readonly ModuleFallbackEntry[]): boolean { + return entries.every(entry => moduleFallbackEntryCurrent(modulesDir, entry)) +} + +/** + * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one + * entry per package in the dsh app's resolvable dependency CLOSURE (BFS + * over `dependencies` from the app manifest), each resolved from its own + * installation location. Plain Node uses symlinks. A pkg executable resolves + * exports under ESM import conditions and writes small proxy packages because + * the host filesystem cannot follow a symlink into pkg's virtual `/snapshot` + * tree; the proxy re-exports the virtual URL, preserving the executable's + * single module instance. A complete matching generation returns without a + * writer lock; actual repairs acquire and recheck one cross-process lock so + * partial proxies and carrier transitions remain serialized. Node's + * parent-directory walk from any profile finds this + * directory after the profile's own `node_modules`, so every in-box plugin + * resolves without pnpm ever managing it — the exact "bundles come from the + * installation" contract. The closure (not just direct dependencies) is + * required for out-of-tree plugins: their peer dependencies name Service + * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app + * reaches only through its Service Provider packages. Both a symlink target + * and a proxy's virtual target resolve transitive imports from the original + * package directory, so each package needs one flat fallback entry. + * Idempotent: correct entries are kept and changed installation targets are + * rewritten; under plain Node, a stale dangling link stays until its name is + * reused because resolution cannot discover it. + * @param installAnchor - absolute path of the dsh app's package.json. + * @param home - the Harness home; defaults to {@link resolveDshHome}. + * @returns settlement after current-state validation or a locked repair. + */ +export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise { + const profilesDir = join(home, PROFILES_DIR) + const modulesDir = join(profilesDir, 'node_modules') + mkdirSync(modulesDir, { recursive: true }) + const entries = resolveModuleFallbackEntries(installAnchor) + if (moduleFallbackCurrent(modulesDir, entries)) return + await withFileLock(modulesDir, () => { + if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir) + return Promise.resolve() + }) +} + +/** Heal one module-fallback generation while the cross-process writer lock is held. */ +function healProfilesModuleFallbackLocked(entries: readonly ModuleFallbackEntry[], modulesDir: string): void { + for (const entry of entries) { + const link = join(modulesDir, entry.packageName) mkdirSync(dirname(link), { recursive: true }) - if (isPackagedExecutable()) { - const source = packageProxySource(packageName, target) - if (Object.keys(source.targets).length > 0) { - ensureModuleProxy(link, packageName, source.version, source.targets) - } + if (entry.kind === 'proxy') { + ensureModuleProxy(link, entry.packageName, entry.version, entry.targets) } else { - ensureSymlink(link, target) + ensureSymlink(link, entry.packageDir) } } } diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 552bf79174..2e3b4e6187 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -4,7 +4,7 @@ * empty-root composition, and the installation module-fallback healing. */ -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' @@ -321,6 +321,20 @@ describe('healProfilesModuleFallback', () => { expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') }) + it('retains current links while repairing a missing sibling', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules') + await healProfilesModuleFallback(anchor, home) + const appTarget = readlinkSync(join(fallback, 'dsh-app')) + unlinkSync(join(fallback, 'bundle-a')) + + await healProfilesModuleFallback(anchor, home) + + expect(readlinkSync(join(fallback, 'dsh-app'))).toBe(appTarget) + expect(lstatSync(join(fallback, 'bundle-a')).isSymbolicLink()).toBe(true) + }) + it('serializes concurrent healers and retains the identical link', async () => { const anchor = stageInstallation({}) const home = tmp() @@ -332,6 +346,31 @@ describe('healProfilesModuleFallback', () => { expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) }) + it('does not acquire the writer lock for a complete generation', async () => { + const anchor = stageInstallation({}) + const home = tmp() + const modules = join(home, 'profiles', 'node_modules') + await healProfilesModuleFallback(anchor, home) + let releaseLock: (() => void) | undefined + let reportLock: (() => void) | undefined + const lockHeld = new Promise((resolve) => { reportLock = resolve }) + const release = new Promise((resolve) => { releaseLock = resolve }) + const holder = withFileLock(modules, async () => { + reportLock?.() + await release + }) + await lockHeld + + const healer = healProfilesModuleFallback(anchor, home) + const outcome = await Promise.race([ + healer.then(() => 'complete' as const), + new Promise<'blocked'>(resolve => setTimeout(() => { resolve('blocked') }, 100)), + ]) + releaseLock?.() + await Promise.all([holder, healer]) + expect(outcome).toBe('complete') + }) + it('waits for the module-fallback writer lock before publishing entries', async () => { const anchor = stageInstallation({}) const home = tmp() From 8dc3b0380e96a5b1a7c9e2d8b7641a1af8b07e99 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:50:41 +0800 Subject: [PATCH 30/42] feat(bundle): ship the standalone sdk-minimal profile Add a startup-only sdk-minimal template whose sole bundle inserts the complete JSON-RPC agent tree over the empty profile root. The roster is an explicit composition allowlist: it contains one DeepSeek adapter, the minimal agent spine, persistent Bash, the string-replace editor, local execution, and JSONL persistence, while dsh-base and Web remain absent. Reuse the SDK app startup provider so the new profile retains help, stdin EOF, and bounded launcher shutdown semantics. Make that provider render its configured profile name, which keeps both sdk and sdk-minimal help truthful without duplicating process lifecycle code. Register the package in the CLI closure, TypeScript graph, lockfile, Knip policy, and bilingual bundle references. Exact manifest, row-roster, profile-template, config-dump, and HMR tests make later additions visible instead of relying on a blacklist. --- apps/cli/package.json | 1 + apps/cli/tests/built-bin.e2e.ts | 33 +++++++ apps/cli/tests/profile-hmr.spec.ts | 6 +- knip.json | 5 + packages/boot/app-boot/src/profile.ts | 4 + packages/boot/app-boot/tests/profile.spec.ts | 4 + packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 1 + packages/bundle/README.zh.md | 1 + packages/bundle/sdk-app/README.i18n.yaml | 4 +- packages/bundle/sdk-app/README.md | 6 +- packages/bundle/sdk-app/README.zh.md | 6 +- packages/bundle/sdk-app/cordis.patch.yml | 2 + packages/bundle/sdk-app/package.json | 1 + packages/bundle/sdk-app/src/index.ts | 24 ++++- packages/bundle/sdk-app/tests/startup.spec.ts | 10 +- packages/bundle/sdk-app/tsconfig.json | 3 + packages/bundle/sdk-minimal/README.i18n.yaml | 6 ++ packages/bundle/sdk-minimal/README.md | 31 ++++++ packages/bundle/sdk-minimal/README.zh.md | 31 ++++++ packages/bundle/sdk-minimal/cordis.patch.yml | 97 +++++++++++++++++++ packages/bundle/sdk-minimal/package.json | 67 +++++++++++++ packages/bundle/sdk-minimal/src/index.ts | 9 ++ packages/bundle/sdk-minimal/src/invariant.ts | 26 +++++ .../sdk-minimal/tests/sdk-minimal.spec.ts | 59 +++++++++++ packages/bundle/sdk-minimal/tsconfig.json | 18 ++++ pnpm-lock.yaml | 64 ++++++++++++ tsconfig.host.json | 1 + 28 files changed, 510 insertions(+), 14 deletions(-) create mode 100644 packages/bundle/sdk-minimal/README.i18n.yaml create mode 100644 packages/bundle/sdk-minimal/README.md create mode 100644 packages/bundle/sdk-minimal/README.zh.md create mode 100644 packages/bundle/sdk-minimal/cordis.patch.yml create mode 100644 packages/bundle/sdk-minimal/package.json create mode 100644 packages/bundle/sdk-minimal/src/index.ts create mode 100644 packages/bundle/sdk-minimal/src/invariant.ts create mode 100644 packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts create mode 100644 packages/bundle/sdk-minimal/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 49f7fc84d1..72e81a8208 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-sdk-app": "workspace:^", + "@deepseek-ai/dsh-sdk-minimal": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 2fffff6700..397e960075 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -12,7 +12,9 @@ import { type SessionNotification, } from '@agentclientprotocol/sdk' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { execa } from 'execa' +import * as yaml from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ @@ -924,6 +926,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) }, 30_000) + it('prints the exact standalone sdk-minimal tree without dsh-base', async () => { + const { stdout, code, stderr } = await runBuiltBin( + ['--profile', 'sdk-minimal', '--dump-default-config'], + { DSH_HOME: home }, + ) + expect(code).toBe(0) + expect(stderr).toBe('') + const rows = yaml.load(stdout, { schema: entryListSchema }) as Array<{ id?: string; name?: string }> + expect(rows.map(row => [row.id, row.name])).toEqual([ + ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'], + ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'], + ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'], + ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'], + ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'], + ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'], + ['sandbox', '@deepseek-ai/dsh-sandbox-local'], + ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], + ['subprocess', '@deepseek-ai/dsh-subprocess-local'], + ['pty', '@deepseek-ai/dsh-terminal'], + ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['fs-local', '@deepseek-ai/dsh-fs-local'], + ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], + ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], + ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ]) + expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') + expect(stdout).not.toContain('@deepseek-ai/dsh-base') + expect(stdout).not.toContain('@deepseek-ai/dsh-web-app') + }, 30_000) + it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) diff --git a/apps/cli/tests/profile-hmr.spec.ts b/apps/cli/tests/profile-hmr.spec.ts index 6fed867d92..306bfe1458 100644 --- a/apps/cli/tests/profile-hmr.spec.ts +++ b/apps/cli/tests/profile-hmr.spec.ts @@ -9,7 +9,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' const REPOSITORY_ROOT = fileURLToPath(new URL('../../../', import.meta.url)) /** Load one shipped bundle patch through the same parser as profile boot. */ -function bundle(name: 'acp-app' | 'base' | 'headless' | 'sdk-app' | 'web-app'): PatchOptions[] { +function bundle(name: 'acp-app' | 'base' | 'headless' | 'sdk-app' | 'sdk-minimal' | 'web-app'): PatchOptions[] { return loadOverlayPatches('profile-hmr test', join(REPOSITORY_ROOT, 'packages', 'bundle', name, 'cordis.patch.yml')) } @@ -39,4 +39,8 @@ describe('profile module-HMR policy', () => { config: { root: ['.'] }, }) }) + + it('keeps the standalone sdk-minimal tree free of module HMR', () => { + expect(composeEntries([bundle('sdk-minimal')]).find(entry => entry.id === 'hmr')).toBeUndefined() + }) }) diff --git a/knip.json b/knip.json index 6e03ebf7da..e8dc6139ee 100644 --- a/knip.json +++ b/knip.json @@ -699,6 +699,11 @@ "@deepseek-ai/dsh-sdk-jsonrpc-server" ] }, + "packages/bundle/sdk-minimal": { + "ignoreDependencies": [ + "@deepseek-ai/.+" + ] + }, "packages/bundle/web-app": { "ignoreDependencies": [ "@deepseek-ai/.+" diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 17c6cf65ab..b99dca29c7 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -146,6 +146,10 @@ export const PROFILE_TEMPLATES: Record = { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'], patchReload: 'startup', }, + 'sdk-minimal': { + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }, } /** Installation-owned bundle tuples normalized to the shipped template. */ diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 2e3b4e6187..4e7a2c5ddf 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -173,6 +173,10 @@ describe('loadProfile', () => { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'], patchReload: 'startup', }) + expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({ + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }) try { loadProfile('t', 'web', anchor, home) } catch { diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 5f7fbd40f4..1ca618d528 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/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/bundle/README.md -README.md: d6b24a276fa64bb2eb80c2aad1783795e351ebc4 -README.zh.md: 36acc510cfab7979d28052ab26687dce58175155 +README.md: 44cf46a217e078e4d71d838afb6525f2bc908bd2 +README.zh.md: f2987b72c526698e2df949e2edfe55e1375f2d8e diff --git a/packages/bundle/README.md b/packages/bundle/README.md index d6b24a276f..44cf46a217 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -13,5 +13,6 @@ The manifest declaration, not this directory, defines Bundle identity. Domain pa | [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows | | [`headless/`](headless/README.md) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` | | [`sdk-app/`](sdk-app/README.md) | SDK stdio JSON-RPC application over base | mounts the SDK server | +| [`sdk-minimal/`](sdk-minimal/README.md) | Standalone minimal SDK application without base or Web | — (complete patch tree) | In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile add `. diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 36acc510cf..f2987b72c5 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -13,5 +13,6 @@ Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包 | [`web-app/`](web-app/README.zh.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 | | [`headless/`](headless/README.zh.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` | | [`sdk-app/`](sdk-app/README.zh.md) | 运行在 base 之上的 SDK stdio JSON-RPC 应用 | 挂载 SDK server | +| [`sdk-minimal/`](sdk-minimal/README.zh.md) | 不含 base 或 Web 的独立极简 SDK 应用 | 无(完整 patch 树) | 内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile add ` 安装进 profile。 diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index 8deaf213fa..43e0f90976 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/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/bundle/sdk-app/README.md -README.md: 0356d6f4a99d7baef6ff7619d505392ff7f7f1d2 -README.zh.md: c70eb685954ebff42bca6c3d289ab58e46298d50 +README.md: c5022bd2096fae931fff48944bfc167280e62476 +README.zh.md: 687a19dc09679d11696a207ad17db3cf463ee901 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index 0356d6f4a9..c5022bd209 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -2,10 +2,14 @@ English | [中文](README.zh.md) -The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. +The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider and supplies its own profile name. The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. A deployment selects a different complete composition through profile bundles and patch files, not another app bin. +| Config | Default | Behavior | +|---|---|---| +| `profile` | `sdk` | Profile name rendered in command help; a bundle mounting this provider sets its own shipped profile name. | + `DSH_MAX_TOKENS_AS_SUCCESS` retains the SDK deployment mapping: unset or JSON `true` reports token-limited subagent completion as accepted, while JSON `false` reports it as an error. Provider/model and workspace cwd arrive through the SDK initialization request; the base profile owns adapters, tools, persistence, policy, settings, and credentials. ## Model Experience diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index c70eb68595..687a19dc09 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -2,10 +2,14 @@ [English](README.md) | 中文 -以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。 +以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) 组合包复用同一个启动提供方,并提供自己的 profile 名称。 启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。 +| 配置 | 默认值 | 行为 | +|---|---|---| +| `profile` | `sdk` | 命令 help 中呈现的 profile 名称;挂载此提供方的组合包会设置自己的随附 profile 名称。 | + `DSH_MAX_TOKENS_AS_SUCCESS` 保留 SDK 部署映射:未设置或 JSON `true` 把 token 达限的 subagent 完成报告为已接受,JSON `false` 则报告为错误。模型提供方/模型与工作区 cwd 通过 SDK 初始化请求传入;base profile 拥有适配器、工具、持久化、策略、settings 与 credentials。 ## 模型体验 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index aa1795168c..373e7aeb63 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -11,6 +11,8 @@ - insert: - id: sdk-app-startup name: '@deepseek-ai/dsh-sdk-app' + config: + profile: sdk - id: sdk-jsonrpc-server name: '@deepseek-ai/dsh-sdk-jsonrpc-server' diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 87214882fb..6ab4167d8d 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -41,6 +41,7 @@ "dependencies": { "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0" }, "peerDependencies": { diff --git a/packages/bundle/sdk-app/src/index.ts b/packages/bundle/sdk-app/src/index.ts index 9ade81a965..fec847af53 100644 --- a/packages/bundle/sdk-app/src/index.ts +++ b/packages/bundle/sdk-app/src/index.ts @@ -7,6 +7,7 @@ import { Command } from 'commander' import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { exitOnStdinEnd, parseCmdline } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ @@ -18,18 +19,30 @@ export const inject = ['cmdlineArgs'] /** Service the JSON-RPC server row waits for before claiming stdio. */ export const SDK_APP_STARTUP_SERVICE = 'sdkAppStartup' +/** SDK stdio startup configuration. */ +export interface Config { + /** Profile name rendered in help and diagnostics (default `sdk`). */ + profile?: string +} + +/** Validate and default SDK stdio startup configuration. */ +export const Config: z = z.object({ + profile: z.string().default('sdk'), +}) + /** * Build this app's zero-option command and help. + * @param profile - selected profile name rendered in the command grammar. * @returns a fresh program for one invocation. */ -function sdkCommand(): Command { +function sdkCommand(profile: string): Command { return new Command() - .name('dsh --profile sdk') + .name(`dsh --profile ${profile}`) .description('Serve DeepSeek Harness SDK clients over stdio JSON-RPC.') .helpOption('-h, --help', 'show this help') .addHelpText('after', ` Example: - dsh --profile sdk serve one SDK runtime until its client disconnects + dsh --profile ${profile} serve one SDK runtime until its client disconnects `) } @@ -37,9 +50,10 @@ Example: * Accept an SDK profile invocation, publish readiness, and bind EOF to the * launcher's bounded shutdown. * @param ctx - plugin context carrying command-line and exit launcher values. + * @param config - selected profile identity for command help. */ -export function apply(ctx: Context): void { - const program = sdkCommand() +export function apply(ctx: Context, config: Config = {}): void { + const program = sdkCommand(config.profile ?? 'sdk') program.action(() => { exitOnStdinEnd(ctx, 'sdk-app.stdin') ctx.provide(SDK_APP_STARTUP_SERVICE, { accepted: true }) diff --git a/packages/bundle/sdk-app/tests/startup.spec.ts b/packages/bundle/sdk-app/tests/startup.spec.ts index 65f2b76c5d..5f0ec6524a 100644 --- a/packages/bundle/sdk-app/tests/startup.spec.ts +++ b/packages/bundle/sdk-app/tests/startup.spec.ts @@ -27,7 +27,7 @@ afterEach(() => { }) /** Run the provider with captured command output and exit requests. */ -function start(args: string[]): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { +function start(args: string[], profile = 'sdk'): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { const ctx = new Context() const exits: number[] = [] const stdin = new TestStdin() @@ -41,7 +41,7 @@ function start(args: string[]): { ctx: Context; exits: number[]; out: () => stri exit: code => void exits.push(code), ready: { onReady: (listener) => { listener(); return () => {} } }, }) - apply(ctx) + apply(ctx, { profile }) return { ctx, exits, out: () => out, stdin } } @@ -62,4 +62,10 @@ describe('SDK app startup', () => { stdin.end() expect(exits).toEqual([0]) }) + + it('renders the selected SDK profile name in help', () => { + const { out } = start(['--help'], 'sdk-minimal') + expect(out()).toContain('Usage: dsh --profile sdk-minimal') + expect(out()).toContain('dsh --profile sdk-minimal') + }) }) diff --git a/packages/bundle/sdk-app/tsconfig.json b/packages/bundle/sdk-app/tsconfig.json index 1d644141bd..0a98d3117c 100644 --- a/packages/bundle/sdk-app/tsconfig.json +++ b/packages/bundle/sdk-app/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../runtime-diagnostics/invariants" }, diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml new file mode 100644 index 0000000000..51cf2a832d --- /dev/null +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/bundle/sdk-minimal/README.md +README.md: 5bc60991624ab52b3d55acac4851b6af2b198218 +README.zh.md: 7e11140bcb8a81cf3e22db88530bfcb364436f96 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md new file mode 100644 index 0000000000..5bc6099162 --- /dev/null +++ b/packages/bundle/sdk-minimal/README.md @@ -0,0 +1,31 @@ +# `@deepseek-ai/dsh-sdk-minimal` + +English | [中文](README.zh.md) + +Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its single insert is the complete Cordis tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a persistent Bash PTY, the string-replace editor, and uncompressed JSONL session persistence under `$DSH_HOME/sessions`. It deliberately does not include [`dsh-base`](../base/README.md), Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs tools, subagents, or any other model-facing tool. + +The profile remains part of the ordinary launcher and layering model. The bundle supplies the complete default tree; the profile patch, home patch, and ordered `--patch` files can replace rows or insert external bundles above it. `dsh plugin --profile sdk-minimal` manages persistent dependencies. The shipped template uses startup-only patches so one stdio connection never observes replacement of its server or agent dependencies. + +`DEEPSEEK_API_KEY` supplies the adapter credential. `DSH_MODEL` selects the sole configured model, `DSH_CONTEXT_WINDOW` sets its context window, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. + +## Model Experience + +### Minimal coding-agent composition + +#### What the model sees + +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` and `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. + +#### Token effect + +One stable persona plus the two tool schemas. Tool results and ordinary conversation history grow with the session. + +#### KV Cache effect + +Stable for a fixed persona, platform, provider, model, and bundle patch stack. Profile changes take effect on the next process. + +## Known Limitations and Deferred Work + +- **The profile is POSIX-only** — this layer uses a Bash PTY; Windows support belongs to the platform runtime layer above it. +- **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. +- **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md new file mode 100644 index 0000000000..7e11140bcb --- /dev/null +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -0,0 +1,31 @@ +# `@deepseek-ai/dsh-sdk-minimal` + +[English](README.md) | 中文 + +供 `dsh --profile sdk-minimal` 使用的独立极简 SDK 应用组合包。它的单个 insert 构成完整 Cordis 树:SDK stdio 启动与 JSON-RPC 对外服务、一个由环境配置的 DeepSeek 适配器、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话持久化。它刻意不包含 [`dsh-base`](../base/README.zh.md)、Web、settings、托管凭据、遥测、压缩(compaction)、workspace 指令、skills、jobs 工具、subagent 或任何其他面向模型的工具。 + +该 profile 仍遵循普通 launcher 与分层模型。组合包提供完整默认树;profile patch、home patch 与有序 `--patch` 文件可以在其上替换配置项或插入外部组合包。`dsh plugin --profile sdk-minimal` 管理持久依赖。随附模板仅在启动时应用 patch,因此一个 stdio 连接不会观察到服务器或 agent 依赖在运行中被替换。 + +`DEEPSEEK_API_KEY` 提供适配器凭据。`DSH_MODEL` 选择唯一配置的模型,`DSH_CONTEXT_WINDOW` 设置其上下文窗口,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 + +## 模型体验 + +### 极简 coding agent 组合 + +#### 模型看到的内容 + +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 agent 所有的持久 `bash` 与 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 + +#### Token 影响 + +一个稳定 persona 加两个工具 schema。工具结果与普通对话历史随会话增长。 + +#### KV Cache 影响 + +当 persona、平台、提供方、模型与组合包 patch 栈固定时保持稳定。Profile 变更在下一个进程生效。 + +## 已知限制与待办工作 + +- **该 profile 仅支持 POSIX** — 此层使用 Bash PTY;Windows 支持属于其上的平台运行时层。 +- **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、权限策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 +- **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml new file mode 100644 index 0000000000..e43f3e2b03 --- /dev/null +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -0,0 +1,97 @@ +# Standalone minimal SDK application. Unlike the ordinary SDK profile, this +# bundle does not layer over dsh-base: this insert is the complete Cordis tree. +# User profile, home, and invocation patches still apply above it. + +- insert: + - id: sdk-app-startup + name: '@deepseek-ai/dsh-sdk-app' + config: + profile: sdk-minimal + + - id: sdk-jsonrpc-server + name: '@deepseek-ai/dsh-sdk-jsonrpc-server' + inject: [sdkAppStartup, loader] + config: + maxTokensAsSuccess: false + + - id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + + - id: session-log-deepseek + name: '@deepseek-ai/dsh-session-log-deepseek' + + - id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKeyEnv: DEEPSEEK_API_KEY + streamIdleTimeoutMs: 172800000 + models: + - id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash' + contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) + + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + + - id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + + - id: pty + name: '@deepseek-ai/dsh-terminal' + + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + config: + timeoutMs: 300000 + + # The editor uses the bare local filesystem; persistent Bash still consumes + # the shared danger-full-access sandbox policy above. + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + + - id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + includeRuntimeContext: false + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + + - id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + + - id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: none diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json new file mode 100644 index 0000000000..1b3d5da5d2 --- /dev/null +++ b/packages/bundle/sdk-minimal/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-sdk-minimal", + "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/sdk-minimal" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-sdk-app": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/bundle/sdk-minimal/src/index.ts b/packages/bundle/sdk-minimal/src/index.ts new file mode 100644 index 0000000000..a5f162161e --- /dev/null +++ b/packages/bundle/sdk-minimal/src/index.ts @@ -0,0 +1,9 @@ +/** + * @deepseek-ai/dsh-sdk-minimal — the standalone minimal SDK profile bundle. + * The package's substance is `cordis.patch.yml`, declared by the + * `dsh.bundle.patch` manifest field and resolved by the profile composer; + * this module carries no runtime interface. + * @module @deepseek-ai/dsh-sdk-minimal + */ + +export {} diff --git a/packages/bundle/sdk-minimal/src/invariant.ts b/packages/bundle/sdk-minimal/src/invariant.ts new file mode 100644 index 0000000000..e2f480504a --- /dev/null +++ b/packages/bundle/sdk-minimal/src/invariant.ts @@ -0,0 +1,26 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-minimal`. + * @module @deepseek-ai/dsh-sdk-minimal/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-minimal' + +/** Cordis companion plugin name. */ +export const name = 'sdk-minimal-bundle-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +// No runtime invariant: the package is a static patch-list carrier whose +// inserted rows own their runtime relationships and invariant companions. +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts new file mode 100644 index 0000000000..4e9fbbcc6a --- /dev/null +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -0,0 +1,59 @@ +/** The standalone SDK-minimal bundle's complete declared Cordis tree. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +describe('dsh-sdk-minimal bundle', () => { + it('declares one standalone allowlisted tree with every row dependency', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + const patches = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) as Array<{ insert?: Array<{ id?: string; inject?: string[]; name?: string; config?: Record }> }> + expect(patches).toHaveLength(1) + const rows = patches[0]?.insert ?? [] + expect(rows.map(row => [row.id, row.name])).toEqual([ + ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'], + ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'], + ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'], + ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'], + ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'], + ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'], + ['sandbox', '@deepseek-ai/dsh-sandbox-local'], + ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], + ['subprocess', '@deepseek-ai/dsh-subprocess-local'], + ['pty', '@deepseek-ai/dsh-terminal'], + ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['fs-local', '@deepseek-ai/dsh-fs-local'], + ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], + ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], + ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ]) + expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' }) + expect(rows.find(row => row.id === 'sdk-jsonrpc-server')).toMatchObject({ + inject: ['sdkAppStartup', 'loader'], + config: { maxTokensAsSuccess: false }, + }) + expect(rows.find(row => row.id === 'agent-spine')?.config).toMatchObject({ + includeHarnessIdentity: false, + includeRuntimeContext: false, + workspaceContext: false, + skills: { enabled: false }, + toolBash: false, + toolJobs: false, + }) + expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual( + [...new Set(rows.map(row => row.name).filter((name): name is string => name !== undefined))].sort(), + ) + }) +}) diff --git a/packages/bundle/sdk-minimal/tsconfig.json b/packages/bundle/sdk-minimal/tsconfig.json new file mode 100644 index 0000000000..8f58ed6e28 --- /dev/null +++ b/packages/bundle/sdk-minimal/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daec43dbff..a6b3e12d45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: '@deepseek-ai/dsh-sdk-app': specifier: workspace:^ version: link:../../packages/bundle/sdk-app + '@deepseek-ai/dsh-sdk-minimal': + specifier: workspace:^ + version: link:../../packages/bundle/sdk-minimal '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session/session-projection @@ -1524,6 +1527,9 @@ importers: '@deepseek-ai/dsh-sdk-jsonrpc-server': specifier: workspace:^ version: link:../../sdk/server + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery commander: specifier: ^15.0.0 version: 15.0.0 @@ -1538,6 +1544,64 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/bundle/sdk-minimal: + dependencies: + '@deepseek-ai/dsh-agent-spine-demo': + specifier: workspace:^ + version: link:../../examples/agent-spine-demo + '@deepseek-ai/dsh-deepseek-llm-api-extensions': + specifier: workspace:^ + version: link:../../llm/deepseek-llm-api-extensions + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-plugin-package-inventory-deepseek': + specifier: workspace:^ + version: link:../../llm/plugin-package-inventory-deepseek + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-sdk-app': + specifier: workspace:^ + version: link:../sdk-app + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:^ + version: link:../../sdk/server + '@deepseek-ai/dsh-session-log-deepseek': + specifier: workspace:^ + version: link:../../session/session-log-deepseek + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:^ + version: link:../../shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../fs/tool-str-replace-editor + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/bundle/web-app: dependencies: '@deepseek-ai/dsh-agent-presets': diff --git a/tsconfig.host.json b/tsconfig.host.json index 91fbfd03f7..0bcbe50df3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -263,6 +263,7 @@ { "path": "./packages/bundle/base" }, { "path": "./packages/bundle/headless" }, { "path": "./packages/bundle/sdk-app" }, + { "path": "./packages/bundle/sdk-minimal" }, { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, { "path": "./packages/boot/cmdline" }, From 79a8f667f71ef5e09fda54fed5079fab93177ebc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:04:24 +0800 Subject: [PATCH 31/42] refactor(python): launch the minimal example through sdk-minimal Make minimal.py select the shipped sdk-minimal profile directly and pass its selected model into the profile-owned adapter catalog. The Python SDK still starts only the bundled dsh CLI with an explicit Harness home; it no longer supplies an invocation overlay for this mode. Drive both the source keyless process test and installed-wheel smoke through the same named profile. The keyless test pins the generated profile manifest and exact two-tool model request, while the packaged smoke keeps the persistent-shell, editor, session-log, and model-visible snapshot evidence. Delete minimal.patch.yml and the unused complete-config/replay fixtures. Their composition now has one owner in @deepseek-ai/dsh-sdk-minimal, so the example and tests cannot drift into separate launch trees. --- examples/python-sdk-agent/minimal.cordis.yml | 90 ------------------- examples/python-sdk-agent/minimal.patch.yml | 47 ---------- examples/python-sdk-agent/minimal.py | 7 +- .../minimal.snapshot.cordis.yml | 20 ----- .../tests/keyless-smoke.e2e.ts | 17 ++-- scripts/smoke-python-runtime.py | 78 ++-------------- 6 files changed, 18 insertions(+), 241 deletions(-) delete mode 100644 examples/python-sdk-agent/minimal.cordis.yml delete mode 100644 examples/python-sdk-agent/minimal.patch.yml delete mode 100644 examples/python-sdk-agent/minimal.snapshot.cordis.yml diff --git a/examples/python-sdk-agent/minimal.cordis.yml b/examples/python-sdk-agent/minimal.cordis.yml deleted file mode 100644 index f849b798a0..0000000000 --- a/examples/python-sdk-agent/minimal.cordis.yml +++ /dev/null @@ -1,90 +0,0 @@ -# Complete minimal-agent composition fixture for lower-level snapshot tests. -# Python users apply `minimal.patch.yml` to `dsh --profile sdk`. This fixture -# retains the same two-tool behavior without defining a supported launch path. - -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' - config: - maxTokensAsSuccess: false - -- id: deepseek-llm-api-extensions - name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' - -- id: session-log-deepseek - name: '@deepseek-ai/dsh-session-log-deepseek' - -- id: plugin-package-inventory-deepseek - name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKeyEnv: DEEPSEEK_API_KEY - streamIdleTimeoutMs: 172800000 - models: - - id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash' - contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: pty - name: '@deepseek-ai/dsh-terminal' - -- id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - config: - timeoutMs: 300000 - -# The editor uses the bare local filesystem; persistent Bash still consumes the -# shared danger-full-access sandbox policy above. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - includeRuntimeContext: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolJobs: false - -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 - description: |- - Run commands in a bash shell - * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. - * You don't have access to the internet via this tool. - * You do have access to a mirror of common linux and python packages via apt and pip. - * State is persistent across command calls and discussions with the user. - * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. - * Please avoid commands that may produce a very large amount of output. - * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. - -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - compression: none diff --git a/examples/python-sdk-agent/minimal.patch.yml b/examples/python-sdk-agent/minimal.patch.yml deleted file mode 100644 index 2c3ac0f5bc..0000000000 --- a/examples/python-sdk-agent/minimal.patch.yml +++ /dev/null @@ -1,47 +0,0 @@ -# Minimal Python SDK overlay for `dsh --profile sdk`. The SDK server applies -# the model-facing allowlist to every root session, so later base-bundle tools -# cannot appear implicitly. The profile still owns JSON-RPC and persistence. - -- id: system-prompt - config: - includeHarnessIdentity: false - includeRuntimeContext: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - personaComplete: true - -- id: sdk-jsonrpc-server - config: - maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" - toolFilter: - allow: - - bash - - str_replace_editor - -# Remove non-tool model inputs and turn rewriting, plus the one-shot Bash row -# that would conflict with the persistent Bash tool inserted below. -- id: agent-instructions - disabled: true -- id: tool-bash - disabled: true -- id: compaction-basic - disabled: true -- id: command-compact - disabled: true -- id: tool-result-pruner - disabled: true - -- id: tool-str-replace-editor - config: - maxOutputChars: 16000 - -- insert: - - id: pty - name: '@deepseek-ai/dsh-terminal' - - id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - config: - timeoutMs: 300000 - - id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 diff --git a/examples/python-sdk-agent/minimal.py b/examples/python-sdk-agent/minimal.py index 255a0dd3c0..86c1dc8082 100644 --- a/examples/python-sdk-agent/minimal.py +++ b/examples/python-sdk-agent/minimal.py @@ -10,9 +10,6 @@ from pathlib import Path from deepseek_harness import DeepSeekHarness -PATCH = Path(__file__).with_name("minimal.patch.yml") - - def main() -> None: """Parse one task and print the agent's final response.""" parser = argparse.ArgumentParser() @@ -24,7 +21,7 @@ def main() -> None: type=Path, default=Path(configured_home) if configured_home.strip() else None, ) - parser.add_argument("--profile", default="sdk") + parser.add_argument("--profile", default="sdk-minimal") parser.add_argument("--session-id") parser.add_argument("--provider", default="deepseek-official") parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) @@ -42,7 +39,7 @@ def main() -> None: cwd=str(workspace), dsh_home=str(dsh_home), profile=args.profile, - patches=(str(PATCH.resolve()),), + env={"DSH_MODEL": args.model}, ) as harness: result = harness.run(args.prompt, session_id=args.session_id) print(result.final_response) diff --git a/examples/python-sdk-agent/minimal.snapshot.cordis.yml b/examples/python-sdk-agent/minimal.snapshot.cordis.yml deleted file mode 100644 index 0f26fa6716..0000000000 --- a/examples/python-sdk-agent/minimal.snapshot.cordis.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Keyless replay keeps the complete minimal composition intact and replaces -# only its live DeepSeek adapter with the fixture-backed provider. The replay -# catalog claims the same route initialized by the SDK. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./minimal.cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek-official - name: DeepSeek - models: - - id: deepseek-v4-flash diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 4b2519c7f8..e0bd4ed6c3 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -10,7 +10,6 @@ import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) -const minimalPatchPath = fileURLToPath(new URL('../minimal.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -181,7 +180,7 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) - it('keeps the minimal overlay on an explicit model-facing allowlist', async () => { + it('boots the standalone minimal profile with its exact model-facing roster', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { @@ -205,16 +204,13 @@ describe('Python SDK dsh profile keyless smoke', () => { 'tsx/esm', binScript, '--profile', - 'sdk', - '--patch', - minimalPatchPath, + 'sdk-minimal', ], { cwd: repoRoot, env: { DSH_HOME: join(root, '.dsh'), - DSH_PERMISSION_MODE: 'danger-full-access', + DSH_MODEL: 'deepseek-v4-pro', DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', - DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -259,6 +255,13 @@ describe('Python SDK dsh profile keyless smoke', () => { } expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor']) + const profile = JSON.parse( + await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), + ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } } + expect(profile.dsh?.profile).toEqual({ + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }) child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) await waitForLine(lines, value => value.id === 3, () => stderr) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 3be8a9bcfb..ba5279000d 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -46,14 +46,6 @@ MINIMAL_BASH_COMMAND = ( "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" ) -MINIMAL_BASH_DESCRIPTION = """Run commands in a bash shell -* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. -* You don't have access to the internet via this tool. -* You do have access to a mirror of common linux and python packages via apt and pip. -* State is persistent across command calls and discussions with the user. -* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. -* Please avoid commands that may produce a very large amount of output. -* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.""" LEGACY_CUSTOM_DISABLED_ROWS = ( "agent-instructions", "goal", @@ -121,10 +113,6 @@ RESTART_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart" ) RESTART_SNAPSHOT_FILENAMES = ("result.json", "requests.json", "session.1.jsonl", "session.2.jsonl") -# The agent loop's dynamic runtime-context snapshot is the one model-visible message this -# expected output cannot carry: the same composition emits it on macOS and not on Linux -# (deepseek-harness#2488), and the file must replay on both. Everything else is compared. -RUNTIME_CONTEXT_PREFIX = "Current runtime context" MCP_SERVER_SCRIPT = """\ import json import os @@ -972,7 +960,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) -> None: - """Exercise the checked-in minimal composition through the packaged executable.""" + """Exercise the shipped standalone minimal profile through the packaged executable.""" from deepseek_harness import DeepSeekHarness # One mock model serves every scenario of a run, so the snapshot takes this turn's slice. @@ -983,59 +971,15 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" dsh_home = root / "home" sessions = dsh_home / "sessions" - disabled = [ - "agent-instructions", - "tool-bash", - "compaction-basic", - "command-compact", - "tool-result-pruner", - ] - patch = write_profile_patch(root, "minimal.patch.yml", sessions, [ - { - "id": "system-prompt", - "config": { - "includeHarnessIdentity": False, - "includeRuntimeContext": False, - "persona": "You are a helpful software engineer assistant.", - "personaComplete": True, - }, - }, - { - "id": "sdk-jsonrpc-server", - "config": { - "maxTokensAsSuccess": True, - "toolFilter": {"allow": ["bash", "str_replace_editor"]}, - }, - }, - *({"id": row_id, "disabled": True} for row_id in disabled), - {"id": "tool-str-replace-editor", "config": {"maxOutputChars": 16000}}, - {"insert": [ - {"id": "pty", "name": "@deepseek-ai/dsh-terminal"}, - { - "id": "terminal-bash", - "name": "@deepseek-ai/dsh-terminal-bash", - "config": {"timeoutMs": 300000}, - }, - { - "id": "persistent-bash", - "name": "@deepseek-ai/dsh-tool-bash-persistent", - "config": { - "timeoutMs": 300000, - "description": MINIMAL_BASH_DESCRIPTION, - }, - }, - ]}, - ]) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), dsh_bin=str(executable), dsh_home=str(dsh_home), - patches=(str(patch),), + profile="sdk-minimal", env={ - "DSH_PERMISSION_MODE": "danger-full-access", - "DSH_TELEMETRY_DISABLED": "1", + "DSH_MODEL": "smoke-model", }, api_key="sk-keyless-smoke", base_url=base_url, @@ -1550,9 +1494,9 @@ def build_minimal_snapshot_files( Every assembled system prompt, advertised tool schema, and system or user message is kept verbatim: they carry what the deployment actually shows the model, so a plugin that contributes an unintended system section or user message cannot pass unnoticed. - Assistant and tool payloads keep only their call identity, and the dynamic - runtime-context snapshot is dropped, because their text differs across the platforms - this expected output must replay on. + Assistant and tool payloads keep only their call identity because their text differs + across the platforms this expected output must replay on. The shipped profile omits + dynamic runtime context, so every message it emits is compared. """ snapshot = [] for body in requests: @@ -1564,21 +1508,11 @@ def build_minimal_snapshot_files( "messages": [ minimal_snapshot_message(message, cwd) for message in messages - if not is_runtime_context_message(message) ], }) return {"model-visible.json": json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n"} -def is_runtime_context_message(message: object) -> bool: - """Identify the agent loop's dynamic runtime-context snapshot, current or cleared.""" - return ( - isinstance(message, dict) - and message.get("role") == "user" - and message_text(message.get("content")).startswith(RUNTIME_CONTEXT_PREFIX) - ) - - def minimal_snapshot_message(message: object, cwd: Path) -> dict[str, object]: """Reduce one model-visible message to its stable, behavior-carrying parts.""" if not isinstance(message, dict): From 7a11f5fde3ad63994a3180dad2c6372f0723562f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:39:43 +0800 Subject: [PATCH 32/42] docs(python): define the standalone minimal profile Record sdk-minimal as the narrow repository-owned exception to base-first profile composition: callers still launch only dsh and cannot provide an arbitrary Cordis tree, while the shipped bundle may own a complete explicit roster. Cross-link the launcher, profile-bundle, Python-runtime, minimal-agent, snapshot, and telemetry decisions; the supersession audit keeps each older note active because its remaining rationale is independent. Update the CLI, architecture, Python tutorial/reference, example, runtime-wheel reference, and bundle documentation. The docs distinguish the full sdk profile from sdk-minimal, explain explicit-home/plugin/patch customization, state the minimal permission and persistence choices, and retain the separately packaged web profile and frontend assets for direct dsh use. Correct dsh-base descriptions to cover base-backed profiles, make SDK startup configuration visible in the generated config catalog, add sdk-minimal to the module graph, and regenerate the base-composition graph. English and Chinese pairs are re-recorded at the exact reviewed contents. --- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 6 +- .../2026-08-05-profile-plugin-bundles.zh.md | 6 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 17 +++--- ...8-22-single-dsh-application-launcher.zh.md | 19 ++++--- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 10 ++-- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 10 ++-- ...4-standalone-sdk-minimal-profile.i18n.yaml | 6 ++ ...26-08-24-standalone-sdk-minimal-profile.md | 57 +++++++++++++++++++ ...08-24-standalone-sdk-minimal-profile.zh.md | 57 +++++++++++++++++++ ...nimal-preset-owns-rl-composition.i18n.yaml | 4 +- ...8-10-minimal-preset-owns-rl-composition.md | 4 +- ...0-minimal-preset-owns-rl-composition.zh.md | 6 +- ...7-31-web-telemetry-default-mount.i18n.yaml | 4 +- .../2026-07-31-web-telemetry-default-mount.md | 4 +- ...26-07-31-web-telemetry-default-mount.zh.md | 4 +- ...l-profiles-bare-two-tool-runtime.i18n.yaml | 4 +- ...-minimal-profiles-bare-two-tool-runtime.md | 10 ++-- ...nimal-profiles-bare-two-tool-runtime.zh.md | 10 ++-- ...n-minimal-model-visible-snapshot.i18n.yaml | 4 +- ...3-python-minimal-model-visible-snapshot.md | 12 ++-- ...ython-minimal-model-visible-snapshot.zh.md | 12 ++-- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 7 ++- apps/cli/README.zh.md | 7 ++- apps/cli/composition.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 9 +-- apps/cli/reference/README.zh.md | 9 +-- apps/cli/src/profile-boot.ts | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 10 ++-- docs/architecture.zh.md | 10 ++-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 18 +++++- docs/config-catalog.zh.md | 18 +++++- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 30 +++++----- docs/user/guide/python-sdk.zh.md | 30 +++++----- examples/python-sdk-agent/README.i18n.yaml | 4 +- examples/python-sdk-agent/README.md | 14 +++-- examples/python-sdk-agent/README.zh.md | 14 +++-- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/bundle/README.i18n.yaml | 4 +- packages/bundle/README.md | 2 +- packages/bundle/README.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/sdk-minimal/README.i18n.yaml | 4 +- packages/bundle/sdk-minimal/README.md | 2 +- packages/bundle/sdk-minimal/README.zh.md | 2 +- packages/examples/README.i18n.yaml | 4 +- packages/examples/README.md | 2 +- packages/examples/README.zh.md | 2 +- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/development.i18n.yaml | 4 +- python/development.md | 2 +- python/development.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 4 +- python/sdk/README.zh.md | 4 +- scripts/gen-doc-graphs.ts | 2 +- 77 files changed, 372 insertions(+), 204 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md create mode 100644 .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index f43fae952f..a70ebb7db2 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: c9f685eecddcd4ea8d8580becef3c32a9693a329 -2026-08-05-profile-plugin-bundles.zh.md: 3f2db5ea7653fde356f6ae8f21898f1fdada3926 +2026-08-05-profile-plugin-bundles.md: 493568691dac3a54185f11cbbf8162bf6b6355b1 +2026-08-05-profile-plugin-bundles.zh.md: adfa95b6f8d0fdd6fe3c0ebbc7a62d935ebb1987 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index c9f685eecd..493568691d 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The default Profile templates use `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. +The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](2026-08-24-standalone-sdk-minimal-profile.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). @@ -27,7 +27,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th ## Consequences -- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape. +- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile, without a repository row for every deployment shape. - `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone. - The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly. -- Backends reject nothing old on disk (pre-release stance): `$DSH_HOME/config.yaml` is simply no longer read. +- Under the pre-release stance, backends carry no compatibility behavior for old on-disk configuration; `$DSH_HOME/config.yaml` is ignored. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 3f2db5ea76..adfa95b6f8 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.zh.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -默认 Profile 模板使用的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.zh.md)负责 headless 组合约定。 +默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.zh.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 @@ -27,7 +27,7 @@ Status: implemented ## Consequences -- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装;仓库不再需要为每种部署形态各留一行。 +- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装,无需在仓库中为每种部署形态各留一行。 - `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。 - 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会响亮失败。 -- 后端不拒绝磁盘上的任何旧格式(发布前姿态):`$DSH_HOME/config.yaml` 只是不再被读取。 +- 按发布前姿态,后端不携带旧磁盘配置的兼容行为;`$DSH_HOME/config.yaml` 会被忽略。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index de2009b1ce..61028c878a 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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/architecture/2026-08-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 27ec766dc62a203a3054ec67828b8897ab5ad245 -2026-08-22-single-dsh-application-launcher.zh.md: 5fb7fb4234b093a6f69446e0d9b1d7fbb7f46cf3 +2026-08-22-single-dsh-application-launcher.md: 48a45cb2454b5532a78474203b6d88aef3dd0697 +2026-08-22-single-dsh-application-launcher.zh.md: dbf2fb3a5bdc16208b0435482d8d0851bd7c44d7 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 27ec766dc6..48a45cb245 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -14,13 +14,13 @@ The Python SDK distributes a native executable and three platform wheels. Its pa ### Launch scope -Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry. +Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, `dsh --profile sdk-minimal`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry. Vendor CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are outside the application-launch inventory. A package app bin or root demo that launches a package entry is not an accepted extension point. ### Profile applications -`@deepseek-ai/dsh-sdk-app` and `@deepseek-ai/dsh-acp-app` compose the protocol applications over `@deepseek-ai/dsh-base`. The SDK bundle adds the JSON-RPC server plus app-owned help and stdio lifetime; the ACP bundle adds the automation-only ACP server plus the same application responsibilities. Both adopt the base model, tools, persistence, settings, credentials, policy, and environment behavior. +`@deepseek-ai/dsh-sdk-app` and `@deepseek-ai/dsh-acp-app` compose the full protocol applications over `@deepseek-ai/dsh-base`. The SDK bundle adds the JSON-RPC server plus app-owned help and stdio lifetime; the ACP bundle adds the automation-only ACP server plus the same application responsibilities. Both adopt the base model, tools, persistence, settings, credentials, policy, and environment behavior. The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) reuses SDK startup and JSON-RPC serving but deliberately owns a complete explicit tree without `dsh-base`. Profile manifests own patch reload: @@ -29,11 +29,12 @@ Profile manifests own patch reload: | `web` | `live` | | `headless` | `startup` | | `sdk` | `startup` | +| `sdk-minimal` | `startup` | | `acp` | `startup` | Custom profiles default to `live`. A startup profile still applies its bundle, profile, home-level, and invocation `--patch` layers, but it does not watch them after boot. `dsh-base` inserts the module-HMR row disabled; a profile with a tested source-module reload lifecycle must enable it explicitly. None of the shipped profiles enable server module HMR: `patchReload: live` uses the launcher's config-only watcher while the startup profiles install no watcher. SDK and ACP cannot safely replace their server, agents, persistence, or tool registry inside one owned stdio connection. -The shipped protocol profiles reserve stdout for protocol frames, expose help without starting transport, and route stdin EOF and signals through bounded root disposal. ACP remains automation-only. The SDK JSON-RPC methods, notification fields, and `initialize.serverInfo.name` remain stable. Model-visible tool and persistence defaults come from `dsh-base`, and runnable snapshots own those assembled application outputs. +The shipped protocol profiles reserve stdout for protocol frames, expose help without starting transport, and route stdin EOF and signals through bounded root disposal. ACP remains automation-only. The SDK JSON-RPC methods, notification fields, and `initialize.serverInfo.name` remain stable. Full-profile model-visible tool and persistence defaults come from `dsh-base`; `sdk-minimal` owns its explicit defaults. Runnable snapshots own the assembled application outputs. ### TypeScript SDK customization @@ -45,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk`, ordered patch files, and an explicit Harness home; the installed `dsh` console command exposes the same profile grammar. The runnable Python example is `examples/python-sdk-agent`. +The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable Python example selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. @@ -57,7 +58,7 @@ The executable family is `deepseek-harness-sdk-runtime--`. The S This decision supersedes the application-launch and package-name facts in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md), [TypeScript SDK client and subagent backend](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md), [remove the SDK project toolchain](../simplification/2026-08-11-remove-sdk-project-toolchain.md), and [single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md). Those notes retain independent authority for profile layering, client/wire semantics, deleted project tooling, and native packaging. -The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) remains authoritative for role-based package names. No active note is fully superseded or eligible for archival. +The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) remains authoritative for role-based package names. The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) partially supersedes this note's base-first rule and complete-tree alternative while retaining this note's launcher ownership. No active note is fully superseded or eligible for archival. ## Alternatives considered @@ -65,7 +66,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o **Keep forwarding compatibility bins.** Rejected: a forwarding executable remains another public launch name and compatibility promise. The pre-release repository can move callers directly to profiles. -**Put complete standalone Cordis trees behind profile wrappers.** Rejected: that centralizes argv without centralizing application composition. `dsh-base` plus thin app bundles gives shared policy one owner while retaining protocol-specific negative guarantees. +**Put caller-supplied complete Cordis trees behind profile wrappers.** Rejected: that centralizes argv without centralizing application composition. Full profiles use `dsh-base` plus thin app bundles so shared policy has one owner. A repository-owned, versioned standalone bundle is allowed only when an explicit roster is the product behavior, as [sdk-minimal](2026-08-24-standalone-sdk-minimal-profile.md) records. **Accept inline plugins or a complete `cordis.yml` in the TypeScript constructor.** Rejected: the SDK would become another package installer and application composer. Named profiles and patch files already provide persistent and per-launch customization through one resolution model. @@ -79,7 +80,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o ## Verification -- Source and built CLI acceptance cover `sdk` and `acp` help, transport startup, stdout purity, EOF, signals, and root disposal. +- Source and built CLI acceptance cover `sdk`, `sdk-minimal`, and `acp` help, transport startup, stdout purity, EOF, signals, and root disposal. - Bundle configuration tests pin module HMR disabled in `dsh-base` and absent from shipped mode overrides; the custom live-profile e2e pins config reload through the launcher's watch-only fallback. - Focused unit suites cover profile launch resolution, initialization bounds, SDK retries, server readiness, and nested isolated homes with 100% coverage on the changed runtime sources. - Keyless ACP and SDK snapshots boot real `dsh` profiles and pin protocol output plus persisted logs; the nested SDK composition boots a second real profile runtime. @@ -91,7 +92,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o - A user changes an SDK application's plugin composition through a named profile and ordered patches, using the same installation and resolution model as every other dsh application. - A custom profile receives live config watching without server module HMR and opts into source-module replacement only through an explicit row override. -- SDK and ACP share the complete base application and one set of policy and tools; snapshots present intentional assembled differences explicitly. +- The full SDK and ACP profiles share the complete base application and one set of policy and tools; `sdk-minimal` owns its explicit standalone roster, and snapshots present intentional assembled differences. - Adding `@deepseek-ai/dsh` increases the TypeScript client's install size in exchange for a deterministic same-version runtime. - Trusted user patches can add a plugin that writes to stdout and corrupt their own protocol stream; shipped profiles guarantee purity, not arbitrary third-party composition. - Python packages the ordinary `dsh` profile launcher while retaining a closed native runtime and no system-Node requirement for wheel users. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index 5fb7fb4234..dbf2fb3a5b 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -14,13 +14,13 @@ Python SDK 分发一个原生可执行文件和三个平台 wheel 包。其打 ### 启动范围 -所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。 +所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk`、`dsh --profile sdk-minimal` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。 Vendor CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于应用启动清单。包应用 bin 或直接启动包入口的根 demo 都不是可接受的扩展点。 ### Profile 应用 -`@deepseek-ai/dsh-sdk-app` 与 `@deepseek-ai/dsh-acp-app` 在 `@deepseek-ai/dsh-base` 之上组合协议应用。SDK 组合包增加 JSON-RPC 服务器、应用自有帮助和 stdio 生命周期;ACP 组合包增加仅用于自动化的 ACP 服务器与相同的应用职责。两者都采用 base 层的模型、工具、持久化、settings、credentials、策略和环境行为。 +`@deepseek-ai/dsh-sdk-app` 与 `@deepseek-ai/dsh-acp-app` 在 `@deepseek-ai/dsh-base` 之上组合完整协议应用。SDK 组合包增加 JSON-RPC 服务器、应用自有帮助和 stdio 生命周期;ACP 组合包增加仅用于自动化的 ACP 服务器与相同的应用职责。两者都采用 base 层的模型、工具、持久化、settings、credentials、策略和环境行为。[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)复用 SDK 启动与 JSON-RPC 服务,但刻意拥有不含 `dsh-base` 的完整显式配置树。 Profile manifest 负责 patch 重载: @@ -29,11 +29,12 @@ Profile manifest 负责 patch 重载: | `web` | `live` | | `headless` | `startup` | | `sdk` | `startup` | +| `sdk-minimal` | `startup` | | `acp` | `startup` | 自定义 profile 默认为 `live`。`startup` profile 仍会应用组合包、profile、home 级与调用时 `--patch` 各层,但启动后不会监视这些文件。`dsh-base` 插入的模块 HMR(热模块替换)配置项默认禁用;具有经过验证的源码模块重载生命周期的 profile 必须显式启用它。随附 profile 均不启用服务器模块 HMR:`patchReload: live` 使用启动器的仅配置 watcher,`startup` profile 则不安装 watcher。SDK 与 ACP 无法在一个自有 stdio 连接内安全替换其服务器、agent、持久化或工具注册表。 -随附协议 profile 将 stdout 保留给协议帧,显示帮助时不启动 transport,并通过有界根节点 dispose(资源释放)处理 stdin EOF 与信号。ACP 继续仅用于自动化。SDK JSON-RPC 方法、通知字段与 `initialize.serverInfo.name` 保持稳定。模型可见工具与持久化默认值来自 `dsh-base`,可运行快照负责钉住这些已组装的应用输出。 +随附协议 profile 将 stdout 保留给协议帧,显示帮助时不启动 transport,并通过有界根节点 dispose(资源释放)处理 stdin EOF 与信号。ACP 继续仅用于自动化。SDK JSON-RPC 方法、通知字段与 `initialize.serverInfo.name` 保持稳定。完整 profile 的模型可见工具与持久化默认值来自 `dsh-base`;`sdk-minimal` 拥有自己的显式默认值。可运行快照负责固定已组装的应用输出。 ### TypeScript SDK 自定义 @@ -45,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端选择 `dsh --profile sdk`、有序 patch 文件和显式 Harness home;安装的 `dsh` 控制台命令暴露相同 profile 语法。可运行的 Python 示例是 `examples/python-sdk-agent`。 +Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;可运行 Python 示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 @@ -57,7 +58,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest 本决策取代 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)、[TypeScript SDK 客户端与 SDK subagent 后端](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)、[移除 SDK 项目工具链](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)和[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的应用启动与包名事实。这些 Note 对 profile 分层、客户端/协议语义、已删除的项目工具链与原生打包仍分别具有独立权威。 -[ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)继续负责基于角色的包名。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。 +[ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)继续负责基于角色的包名。[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)部分取代本 Note 的 base 优先规则与完整配置树替代方案,同时保留本 Note 对 launcher 所有权的决策。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。 ## 考虑过的替代方案 @@ -65,7 +66,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest **保留转发兼容 bin。** 拒绝:转发可执行文件仍然形成另一个公开启动名称与兼容承诺。预发布仓库可以让调用方直接迁移到 profile。 -**把完整独立 Cordis 树放到 profile wrapper 后面。** 拒绝:这只集中 argv,没有集中应用组合。`dsh-base` 加轻量应用组合包让共享策略只有一个归属,同时保留协议专属的负面保证。 +**把调用方提供的完整 Cordis 树放到 profile wrapper 后面。** 拒绝:这只集中 argv,没有集中应用组合。完整 profile 使用 `dsh-base` 加轻量应用组合包,使共享策略只有一个归属。只有当显式清单本身属于产品行为时,才允许仓库自有且有版本的独立组合包,具体见 [sdk-minimal](2026-08-24-standalone-sdk-minimal-profile.zh.md)。 **在 TypeScript 构造函数中接受内联插件或完整 `cordis.yml`。** 拒绝:SDK 会因此成为另一个包安装器和应用组合器。具名 profile 与 patch 文件已通过统一解析模型提供持久与逐次启动自定义。 @@ -79,8 +80,8 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest ## 验证 -- 源码与构建后 CLI 验收覆盖 `sdk` 和 `acp` 的帮助、transport 启动、stdout 纯净性、EOF、信号与根节点 dispose。 -- 组合包配置测试钉住 `dsh-base` 默认禁用模块 HMR,随附模式覆盖层不再重复该策略;自定义 live profile 的 e2e 钉住启动器仅监视 fallback 提供的配置重载。 +- 源码与构建后 CLI 验收覆盖 `sdk`、`sdk-minimal` 和 `acp` 的帮助、transport 启动、stdout 纯净性、EOF、信号与根节点 dispose。 +- 组合包配置测试钉住 `dsh-base` 默认禁用模块 HMR,随附模式覆盖层不含该策略;自定义 live profile 的 e2e 钉住启动器仅监视 fallback 提供的配置重载。 - 聚焦单元套件覆盖 profile 启动解析、初始化时限、SDK 重试、服务器就绪和嵌套隔离 home,并对变更后的运行时源码实现 100% 覆盖率。 - 免密钥 ACP 与 SDK 快照启动真实 `dsh` profile,并钉住协议输出与持久化日志;嵌套 SDK 组合会启动第二个真实 profile 运行时。 - 真实 API 工作流把文件并行度限制为 4,因为一个 profile e2e 文件可能拥有多个完整 `dsh` 子进程树;工作流测试会钉住该资源上限。 @@ -91,7 +92,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest - 用户通过具名 profile 与有序 patch 更改 SDK 应用的插件组合,使用与其他所有 dsh 应用相同的安装与解析模型。 - 自定义 profile 可以在不启用服务器模块 HMR 的情况下获得实时配置监视,只有显式覆盖配置项才会启用源码模块替换。 -- SDK 与 ACP 共享完整 base 应用和同一份策略与工具;快照以显式差异呈现刻意采用的组装变化。 +- 完整 SDK 与 ACP profile 共享完整 base 应用和同一份策略与工具;`sdk-minimal` 拥有自己的显式独立清单,快照会呈现这些刻意采用的组装差异。 - 增加 `@deepseek-ai/dsh` 会扩大 TypeScript 客户端的安装体积,换来确定的同版本运行时。 - 受信任用户 patch 可以增加写入 stdout 的插件并破坏自己的协议流;随附 profile 保证纯净,不为任意第三方组合提供保证。 - Python 打包普通 `dsh` profile 启动器,同时保留封闭原生运行时,wheel 用户无需系统 Node。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 8cc79728eb..f418c62b83 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: e3df2d01e3aef7e6eadaa011d51c9ab87456d35f -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 3d50cac36d469172e91be450cea658f3a4830ccf +2026-08-23-python-sdk-dsh-profile-runtime.md: 3298224688e8f0cd4216f8ed68d9e4364014d2a9 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 400e70113e1112aa6e4979c64a5046199c07e8a0 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index e3df2d01e3..3298224688 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -14,7 +14,7 @@ A normal profile cannot be adopted only at the Python wrapper. The runtime execu ### One application launcher -The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. +The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The runnable minimal example selects the shipped `sdk-minimal` profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path. The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, a bounded initialization timeout, and optional turn/shutdown timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path. @@ -22,9 +22,9 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME ### Plugin customization -Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. +Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile ...` manages external dependencies and bundle order, `$DSH_HOME/profiles//cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A selected profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. -The checked-in minimal overlay retains the shared SDK profile while configuring the server with a root-agent tool allow list. Later base-bundle tools remain unavailable unless the overlay names them. Its deployment persona is a complete system prompt, so unrelated guidance sections cannot describe hidden tools; dynamic runtime context, workspace instructions, compaction, and the conflicting one-shot Bash row are disabled separately. +The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition rather than a server filter; dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices. The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. @@ -38,7 +38,7 @@ The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-w ## Existing decisions and supersession -This decision implements and supersedes the Python exception and deferred-migration sections of [the single dsh application launcher](2026-08-22-single-dsh-application-launcher.md). It supersedes the private application, external complete-config, artifact-name, and customization facts in [the single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md), which remains authoritative for pkg/SEA, wheel construction, native target validation, and publication. No active note is fully superseded, so none is archived. +This decision implements and supersedes the Python exception and deferred-migration sections of [the single dsh application launcher](2026-08-22-single-dsh-application-launcher.md). It supersedes the private application, external complete-config, artifact-name, and customization facts in [the single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md), which remains authoritative for pkg/SEA, wheel construction, native target validation, and publication. The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) supersedes only this note's minimal-overlay realization. No active note is fully superseded, so none is archived. ## Alternatives considered @@ -52,4 +52,4 @@ This decision implements and supersedes the Python exception and deferred-migrat ## Consequences -Python callers configure the same profile vocabulary as TypeScript and direct CLI users, and arbitrary external bundles can extend an SDK profile without replacing the application tree. Homes must now be selected explicitly, complete-config and `session_root` parameters are unavailable, and the executable includes shared-library assets plus profile-module proxies. The stronger installed-wheel CI makes those package, profile, native, and provider paths release requirements rather than source-only assumptions. +Python callers configure the same profile vocabulary as TypeScript and direct CLI users, and arbitrary external bundles can extend an SDK profile without introducing another launcher. Homes are selected explicitly, complete-config and `session_root` parameters are unavailable, and the executable includes shared-library assets plus profile-module proxies. The full `sdk`, standalone `sdk-minimal`, and `web` applications remain separate profiles inside the same packaged CLI. The installed-wheel CI makes those package, profile, native, and provider paths release requirements rather than source-only assumptions. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 3d50cac36d..400e70113e 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -14,7 +14,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ### 一个应用启动器 -运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 +运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。可运行极简示例选择随附 `sdk-minimal` profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。 公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择、有界初始化 timeout,以及可选的轮次/关闭 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。 @@ -22,9 +22,9 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ### 插件自定义 -持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 +持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles//cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。所选 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 -检入的极简 overlay 会保留共享 SDK profile,并为 server 配置根 agent 工具 allow 列表。基础 bundle 后续新增的工具只有在 overlay 指名时才可用。其部署 persona 是完整系统提示词,因此无关引导段不会描述已隐藏的工具;动态运行时上下文、workspace 指令、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。 +[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在,而不是通过 server 筛选;动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 @@ -38,7 +38,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ## 既有决策与取代关系 -本决策实现并取代[单一 dsh 应用启动器](2026-08-22-single-dsh-application-launcher.zh.md)中的 Python 例外与延后迁移章节。它取代[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的私有应用、外部完整配置、产物名称与自定义事实;后者继续负责 pkg/SEA、wheel 构建、原生目标验证与发布。没有任何 active note 被完全取代,因此无需归档。 +本决策实现并取代[单一 dsh 应用启动器](2026-08-22-single-dsh-application-launcher.zh.md)中的 Python 例外与延后迁移章节。它取代[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的私有应用、外部完整配置、产物名称与自定义事实;后者继续负责 pkg/SEA、wheel 构建、原生目标验证与发布。[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只取代本 Note 中的极简 overlay 实现。没有任何 active note 被完全取代,因此无需归档。 ## 考虑过的替代方案 @@ -52,4 +52,4 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml ## 结果 -Python 调用方使用与 TypeScript 和直接 CLI 用户相同的 profile 词汇,任意外部 bundle 可以扩展 SDK profile,而无需替换应用树。调用方现在必须显式选择 home,完整配置与 `session_root` 参数不可用,可执行程序则包含共享库资源和 profile 模块代理。更强的 installed-wheel CI 将包、profile、原生与提供方路径变成发布要求,而不是仅在源码中成立的假设。 +Python 调用方使用与 TypeScript 和直接 CLI 用户相同的 profile 词汇,任意外部 bundle 可以扩展 SDK profile,而无需引入另一个 launcher。调用方必须显式选择 home,完整配置与 `session_root` 参数不可用,可执行程序则包含共享库资源和 profile 模块代理。完整 `sdk`、独立 `sdk-minimal` 与 `web` 应用作为同一打包 CLI 内的不同 profile 保持分离。Installed-wheel CI 将包、profile、原生与提供方路径变成发布要求,而不是仅在源码中成立的假设。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml new file mode 100644 index 0000000000..6fb8fc0362 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/architecture/2026-08-24-standalone-sdk-minimal-profile.md +2026-08-24-standalone-sdk-minimal-profile.md: aa822d40a3b15bb55c36e8bcc585c4aaeb03c13d +2026-08-24-standalone-sdk-minimal-profile.zh.md: cea6ad6f575882b07d9494532e94cfe95bd61b43 diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md new file mode 100644 index 0000000000..aa822d40a3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -0,0 +1,57 @@ +# Agent Note: Standalone sdk-minimal profile under dsh + +Status: implemented + +English | [中文](2026-08-24-standalone-sdk-minimal-profile.zh.md) + +## Problem + +A minimal SDK agent needs an explicit plugin roster. Expressing it as an overlay on the full `sdk` profile leaves every `dsh-base` service mounted and makes exclusion depend on filters and disable entries spread across unrelated plugins. A later base row can change runtime behavior even when the model-facing tools remain filtered. + +A complete caller-supplied Cordis tree gives an exact roster but bypasses profile initialization, bundle resolution, persistent plugin management, home and invocation patch layers, and the `dsh`-owned process lifecycle. The minimal mode needs composition-level exclusion without creating another launcher or Python-owned application. + +## Decision + +### Launch and ownership + +`dsh --profile sdk-minimal` is a shipped startup-only profile. Its manifest lists only `@deepseek-ai/dsh-sdk-minimal`; it does not list `@deepseek-ai/dsh-base`. The bundle inserts the complete Cordis tree over the launcher's empty profile root, while the profile patch, home patch, and ordered invocation patches retain their ordinary precedence above it. + +The `dsh` CLI remains the only application launcher. The Python example selects `sdk-minimal` through the public `profile` field and an explicit Harness home. Python exposes no complete-config or arbitrary-argv path. The full Python and TypeScript SDK defaults remain `sdk`. + +The bundle reuses `@deepseek-ai/dsh-sdk-app` for command help, stdin EOF, and bounded shutdown. The startup provider accepts a profile-name config so both SDK profiles render their actual command without duplicating process lifecycle code. + +### Explicit composition + +The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The persona comes from `DSH_SYSTEM_PROMPT`, the adapter route from `DSH_MODEL` and `DSH_CONTEXT_WINDOW`, and the credential from `DEEPSEEK_API_KEY`. + +Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. This layer is POSIX-only because its persistent terminal uses Bash. + +### Customization and Web + +`dsh plugin --profile sdk-minimal add ` installs persistent dependencies and bundle layers. The profile's `cordis.patch.yml`, the home patch, and Python `patches` provide persistent, machine-local, and invocation-specific row changes. Customization can expand or replace the explicit tree, but it still passes through the same launcher and profile resolution. + +The Python runtime continues to package `dsh-web-app` and the frontend assets. `dsh web` starts that separate browser application from the installed wheel; a Python SDK client cannot select `web` because it contains no JSON-RPC server row. + +## Existing decisions and supersession + +This decision partially supersedes the base-first and standalone-tree rejection in [one dsh launcher for application profiles](2026-08-22-single-dsh-application-launcher.md). Repository-owned, versioned standalone profile bundles are allowed when an explicit roster is the product behavior; caller-supplied complete trees and alternate executables remain rejected. + +It also supersedes the minimal-overlay realization in [Python SDK runtime through the dsh profile launcher](2026-08-23-python-sdk-dsh-profile-runtime.md) and the base-first default-profile statement in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md). Those notes retain independent authority for launcher ownership, Python packaging and home requirements, general profile layering, and plugin management. No active note is fully superseded or eligible for archival. + +## Verification + +The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless Python example test boots the real `dsh --profile sdk-minimal` process and asserts the generated manifest, complete system prompt, and two advertised tools. The installed-wheel minimal scenario exercises persistent shell state, editor effects, JSONL persistence, and the committed model-visible snapshot through the packaged executable. + +## Alternatives considered + +**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also makes the minimal application depend on controls in shared SDK server and system-prompt interfaces. + +**Restore a Python `cordis` argument or environment-selected complete config.** Rejected because it recreates a Python-owned application composition and bypasses profile plugin management and launcher lifecycle. + +**Create a second minimal SDK startup plugin.** Rejected because profile-aware help is the only variation; the SDK startup provider can own that config while keeping EOF and shutdown behavior local. + +**Remove Web packages from the Python runtime closure.** Rejected because the wheel distributes the ordinary `dsh` application and Python deployments may also need `dsh web`; profile selection, not packaging divergence, separates those applications. + +## Consequences + +The minimal model and runtime roster changes only when its owning bundle changes or a trusted higher patch expands it. The price is deliberate duplication of a small complete application tree and omission of shared settings, credentials, policy controls, telemetry, and Web capabilities from that profile. Users choose the full `sdk` profile when they need those services, while both choices keep one launcher, one profile vocabulary, and one packaged runtime. diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md new file mode 100644 index 0000000000..cea6ad6f57 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -0,0 +1,57 @@ +# Agent Note: dsh 下的独立 sdk-minimal profile + +Status: implemented + +[English](2026-08-24-standalone-sdk-minimal-profile.md) | 中文 + +## 问题 + +极简 SDK agent 需要显式插件清单。若把它表达为完整 `sdk` profile 上的 overlay,所有 `dsh-base` 服务仍保持挂载,排除逻辑则依赖分散在无关插件中的筛选器与 disable 配置项。以后新增的 base 配置项即使没有进入面向模型的工具清单,也可能改变运行时行为。 + +由调用方提供完整 Cordis 配置树可以得到确切清单,但会绕过 profile 初始化、组合包解析、持久插件管理、home 与调用 patch 层,以及由 `dsh` 拥有的进程生命周期。极简模式需要在组合层排除功能,同时不能创建另一个 launcher 或 Python 自有应用。 + +## 决策 + +### 启动与所有权 + +`dsh --profile sdk-minimal` 是随附的仅启动时 profile。其 manifest 只列出 `@deepseek-ai/dsh-sdk-minimal`,不列出 `@deepseek-ai/dsh-base`。该组合包在 launcher 的空 profile 根之上插入完整 Cordis 配置树,而 profile patch、home patch 与有序调用 patch 仍在其上保持普通优先级。 + +`dsh` CLI 仍是唯一应用 launcher。Python 示例通过公开 `profile` 字段与显式 Harness home 选择 `sdk-minimal`。Python 不暴露完整配置或任意 argv 路径。完整 Python 与 TypeScript SDK 的默认值仍是 `sdk`。 + +该组合包复用 `@deepseek-ai/dsh-sdk-app` 提供命令 help、stdin EOF 与有界关闭。启动提供方接受 profile 名称配置,因此两个 SDK profile 都能呈现自己的实际命令,且无需复制进程生命周期代码。 + +### 显式组合 + +该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。Persona 来自 `DSH_SYSTEM_PROMPT`,适配器路由来自 `DSH_MODEL` 与 `DSH_CONTEXT_WINDOW`,凭据来自 `DEEPSEEK_API_KEY`。 + +Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。由于持久终端使用 Bash,此层只支持 POSIX。 + +### 自定义与 Web + +`dsh plugin --profile sdk-minimal add ` 安装持久依赖与组合包层。Profile 自身的 `cordis.patch.yml`、home patch 与 Python `patches` 分别提供持久、机器本地和逐次调用的配置项变更。自定义可以扩展或替换显式配置树,但仍经过同一个 launcher 与 profile 解析。 + +Python 运行时继续打包 `dsh-web-app` 与前端产物。`dsh web` 会从已安装 wheel 启动这个独立浏览器应用;Python SDK client 不能选择 `web`,因为其中没有 JSON-RPC server 配置项。 + +## 既有决策与取代关系 + +本决策部分取代[应用 profile 使用同一个 dsh launcher](2026-08-22-single-dsh-application-launcher.zh.md)中的 base 优先规则与独立配置树否决。显式清单属于产品行为时,可以使用仓库自有且有版本的独立 profile 组合包;由调用方提供的完整配置树与替代可执行程序仍被否决。 + +本决策也取代 [Python SDK 运行时通过 dsh profile launcher 启动](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)中的极简 overlay 实现,以及 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)中默认 profile 均以 base 开头的表述。这些 Agent Note 对 launcher 所有权、Python 打包与 home 要求、普通 profile 分层及插件管理仍保持独立权威。没有活跃 Agent Note 被完全取代或符合归档条件。 + +## 验证 + +组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless Python 示例测试启动真实 `dsh --profile sdk-minimal` 进程,并断言生成的 manifest、完整系统提示词与两个对外公布的工具。Installed-wheel 极简场景通过打包可执行程序验证持久 shell 状态、editor 文件效果、JSONL 持久化与已提交的模型可见快照。 + +## 考虑过的替代方案 + +**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为,还会让极简应用依赖共享 SDK server 与系统提示词接口中的控制项。 + +**恢复 Python `cordis` 参数或由环境选择的完整配置。** 否决:这会重新创建 Python 自有应用组合,并绕过 profile 插件管理与 launcher 生命周期。 + +**创建第二个极简 SDK 启动插件。** 否决:唯一变化是 profile 感知的 help;SDK 启动提供方可以拥有该配置,同时把 EOF 与关闭行为保持在一处。 + +**从 Python 运行时闭包移除 Web 包。** 否决:wheel 分发普通 `dsh` 应用,而且 Python 部署也可能需要 `dsh web`;这些应用由 profile 选择隔离,而不是由打包差异隔离。 + +## 后果 + +极简模型与运行时清单只有在所属组合包变化,或受信任的上层 patch 扩展它时才会变化。代价是刻意重复一棵较小的完整应用树,并在该 profile 中省略共享 settings、凭据、策略控制、遥测与 Web 功能。需要这些服务的用户选择完整 `sdk` profile;两种选择仍共用一个 launcher、一套 profile 词汇与一个打包运行时。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index bad485523f..49ebbb6567 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md -2026-08-10-minimal-preset-owns-rl-composition.md: 47a1bbe9f8f4875e2437b3ff955663c4b3de7dce -2026-08-10-minimal-preset-owns-rl-composition.zh.md: 0fab1e1f23a314bec80b5fd355abcb2881c1b1a5 +2026-08-10-minimal-preset-owns-rl-composition.md: 2e9a3e56252f8e91008a5559ad738a7ca678446b +2026-08-10-minimal-preset-owns-rl-composition.zh.md: 31df6ebfbc15f35208bc73b391725b2039fe7819 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md index 47a1bbe9f8..2e9a3e5625 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -22,7 +22,7 @@ The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace at System-prompt and persona package tests prove final complete-section and runtime-context suppression, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web-orientation text, dynamic policy contexts, and a test section are registered, asserts that no runtime-context snapshot exists, the entry-local filesystem is bare, and compaction is absent, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. -The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) is the complete two-tool composition for the bundled JSON-RPC runtime. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. +The standalone [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) is the complete two-tool composition for `dsh --profile sdk-minimal`. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction; the [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns its launcher and bundle placement. Its keyless SDK process test asserts the assembled system prompt and two-tool catalog, and the installed-wheel scenario executes persistent Bash across calls and exercises the editor; the Python SDK tutorial provides the runnable entry point. ## Alternatives considered @@ -36,4 +36,4 @@ The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/mini ## Consequences -The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and standalone JSON-RPC example state the same two-tool contract for their respective launch paths. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset does not support Windows agents. +The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and `sdk-minimal` profile state the same two-tool behavior for their respective launch paths. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset does not support Windows agents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md index 0fab1e1f23..31df6ebfbc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -16,13 +16,13 @@ Status: implemented preset persona 恰好是 `You are a helpful software engineer assistant.`,它设置 `complete: true`,并为其 agent 作用域抑制 runtime context。complete `PromptSection` 参与常规组装,因此工具、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落,并丢弃每个动态上下文贡献。存在多个有效 complete 段时,组装会被拒绝。这些最终注册表约束可防止 harness 身份、Web 定位、工具引导、组装监听器、沙箱策略、批准策略、委派或其他动态上下文提供方添加模型输入。 -进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 会改变一个 agent 面向模型的组合,并且仅为该 agent 遮蔽宿主文件系统提供方,不会改变 Web 进程中的其他会话。 +进程级 `core-web.cordis.yml` patch 缺席。浏览器 UI、workspace 附加、持久化、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 会改变一个 agent 面向模型的组合,并且仅为该 agent 遮蔽宿主文件系统提供方,不会改变 Web 进程中的其他会话。 ## 验证 系统提示词与 persona 包测试证明了 complete 段最终约束与 runtime-context 抑制,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web 定位文本、动态策略上下文和一个测试段落;它断言不存在 runtime-context 快照、entry 本地文件系统是裸后端且压缩不存在,随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 -独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 是内置 JSON-RPC 运行时的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无压缩选择。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 +独立的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)是 `dsh --profile sdk-minimal` 的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无 compaction 选择;[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责其 launcher 与组合包位置。其无密钥 SDK 进程测试会断言组装后的系统提示词与双工具目录,installed-wheel 场景会跨调用执行持久 Bash 并使用编辑器;Python SDK 教程提供可运行入口。 ## 考虑过的替代方案 @@ -36,4 +36,4 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,它 ## 后果 -Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与独立 JSON-RPC 示例分别在各自的启动路径声明相同的双工具约定。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 +Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与 `sdk-minimal` profile 分别为各自启动路径声明相同的双工具行为。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 5d04c6b846..2d37aaba9f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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-07-31-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: 9399cbced62e326a46058a5f0c21c949e8737eff -2026-07-31-web-telemetry-default-mount.zh.md: 133c5293fce676567a9ef8d2a47196678f4dc0e4 +2026-07-31-web-telemetry-default-mount.md: a492356eccba9f272ee216777eb518750c7b6b62 +2026-07-31-web-telemetry-default-mount.zh.md: 3d852229c069ae32f328c8dae38cfdf1744c7293 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index 9399cbced6..a492356ecc 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -10,7 +10,7 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry ## Decision -The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every profile has one consistent telemetry capability. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps that row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. +The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps the mounted row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. | Ruling | Value | Rationale | |---|---|---| @@ -27,7 +27,7 @@ The base bundle test pins the shipped `DISABLED` mode expression, the backend su ## Alternatives considered -**No default mount; deployments add the row themselves.** Rejected because the mounted `DISABLED` mode retains a local feedback warning and gives all profiles one patch target without authorizing any upload. +**No default mount; deployments add the row themselves.** Rejected because the mounted `DISABLED` mode retains a local feedback warning and gives every base-backed profile one patch target without authorizing any upload. **A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 133c5293fc..3d852229c0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个 profile 都具有一致的遥测能力。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让该配置行保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 +共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让已挂载配置项保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 | 决策项 | 取值 | 理由 | |---|---|---| @@ -28,7 +28,7 @@ Status: implemented ## 考虑过的替代方案 -**默认不挂载,部署方自行添加配置行。** 不采用:挂载的 `DISABLED` 模式会保留本地反馈警告,并为所有 profile 提供同一个 patch 目标,同时不授权任何上传。 +**默认不挂载,部署方自行添加配置行。** 不采用:挂载的 `DISABLED` 模式会保留本地反馈警告,并为每个基于 base 的 profile 提供同一个 patch 目标,同时不授权任何上传。 **开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index c548bbbcad..58474befe4 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 7d068aebb6642602aac0a039c8635acf555ccfe8 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: c32a9a2e09ff0acda592062f780427c636fd65dd +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: dbbe0e253d23a7663bdd40ac302e779d3090e8e0 +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 831b475f277beeef7e7542fb23c410311b45151b diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index 7d068aebb6..dbbe0e253d 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -12,17 +12,17 @@ The two launch paths also have different configuration owners. Web mounts a per- ## Decision -Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. Sandbox and approval services remain mounted and enforce their policies; only their model-facing dynamic context is absent. +Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. -The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) remains a complete JSON-RPC process composition. It mounts `dsh-sdk-jsonrpc-server`, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the deployment's danger-full-access sandbox policy; the editor is not confined by that policy. +The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. -`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) also uses `DSH_MODEL` as its default `model` argument; an explicit `--model` remains authoritative. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. +`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) uses `DSH_MODEL` as its default `model` argument and passes an explicit `--model` back to the child environment so the catalog and request remain aligned. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. ## Verification The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. -The SDK replay boots the real JSON-RPC agent process through the SDK client, injects an environment-selected prompt, asserts the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message, and executes both tools. Python SDK bundled-runtime coverage initializes the standalone configuration through each available packaged carrier with environment-selected model, model capacity, and prompt values. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes both tools. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered @@ -36,4 +36,4 @@ The SDK replay boots the real JSON-RPC agent process through the SDK client, inj ## Consequences -Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path continues to communicate only through the bundled stdio JSON-RPC runtime. +Minimal sessions never summarize or replace earlier history and never add a runtime-context snapshot; callers must keep turns within the selected model's context capacity and must not rely on model-visible narration of standing sandbox or approval policy. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool, no-context, and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path communicates only through the bundled `dsh` stdio JSON-RPC profile. diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index c32a9a2e09..831b475f27 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -12,17 +12,17 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` ## 决策 -两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。沙箱与批准服务仍保持挂载并强制其策略;只有它们面向模型的动态上下文缺席。 +两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 -独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 仍是完整的 JSON-RPC 进程组合。它挂载 `dsh-sdk-jsonrpc-server`、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及未压缩的 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费部署的 danger-full-access 沙箱策略;编辑器不受该策略限制。 +独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 -`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)也使用 `DSH_MODEL` 作为 `model` 参数的默认值;显式 `--model` 仍具有最高优先级。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 +`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)会使用 `DSH_MODEL` 作为 `model` 参数的默认值,并把显式 `--model` 传回子进程环境,使目录与请求保持一致。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 ## 验证 Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 -SDK 回放通过 SDK 客户端启动真实 JSON-RPC agent 进程,注入由环境选择的提示词,断言组装提示词与精确双工具目录,另外断言不存在任何 system-prompt 拥有的 runtime-context 消息,并执行两个工具。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立配置。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行两个工具。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 @@ -36,4 +36,4 @@ SDK 回放通过 SDK 客户端启动真实 JSON-RPC agent 进程,注入由环 ## 后果 -Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径继续仅通过内置 stdio JSON-RPC 运行时通信。 +Minimal 会话不会摘要或替换较早历史,也不会添加 runtime-context 快照;调用方必须让会话轮次保持在所选模型的上下文容量内,且不得依赖模型可见的常驻沙箱或批准策略说明。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具、无上下文与无压缩保证,同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径只通过内置 `dsh` stdio JSON-RPC profile 通信。 diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml index d2eb7495d9..534ca917cc 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.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/testing/2026-08-13-python-minimal-model-visible-snapshot.md -2026-08-13-python-minimal-model-visible-snapshot.md: 37c76be93fb4f18fa99ceec7c15d20e572f2dfb3 -2026-08-13-python-minimal-model-visible-snapshot.zh.md: 5c2b0dd5fcd6ec5ea3a68eb884e19b0917bbe0be +2026-08-13-python-minimal-model-visible-snapshot.md: 1cef57c440ddd2209628016ab550174b448587bc +2026-08-13-python-minimal-model-visible-snapshot.zh.md: fbbb3bd6f4c3edadb61a02eebbae3dfaa97cb8e7 diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md index 37c76be93f..1cef57c440 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md @@ -6,15 +6,13 @@ English | [中文](2026-08-13-python-minimal-model-visible-snapshot.zh.md) ## Problem -The Python lane never compared what the minimal composition actually shows the model. Dynamic runtime context reaches history as a user message, so the mock model's assertion that system-role messages equal the deployment persona could not see it, and the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. The sandbox-policy runtime-context message therefore rode along in the checked-in [minimal composition](../../../../examples/python-sdk-agent/minimal.cordis.yml) while `python-runtime` stayed green, and any plugin that adds a system section, a tool, or another context message could do the same. +The Python lane needs an exact record of what the standalone minimal profile shows the model. Functional tool assertions prove execution but do not reveal an added system section, tool description, or user-role context message, while the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. ## Decision -The `sdk-minimal` scenario in [the packaged-runtime smoke](../../../../scripts/smoke-python-runtime.py) records `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`: for every model request of the turn, the advertised tool schemas verbatim and the message list. System and user messages keep their full text with the scenario's temporary directory tokenized; assistant and tool messages keep only call identity, because their PTY and filesystem text differs across the platforms the expected output replays on. +The `sdk-minimal` scenario in [the packaged-runtime smoke](../../../../scripts/smoke-python-runtime.py) boots the shipped profile and records `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`: for every model request of the turn, the advertised tool schemas verbatim and the message list. System and user messages keep their full text with the scenario's temporary directory tokenized; assistant and tool messages keep only call identity, because their PTY and filesystem text differs across replay platforms. The profile omits dynamic runtime context, so every message it emits is compared. -One model-visible message is excluded: the agent loop's dynamic runtime-context snapshot. The same composition emits it on macOS and not on Linux, which the required lane runs, so no single expected output can carry it. That difference is a defect in its own right ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)) — this expected output covers every other model-visible message rather than waiting for it. - -The mock model no longer asserts the minimal scenario's tools and system prompts — the snapshot owns that surface and reports a complete diff instead of the first mismatch. Snapshot comparison takes its directory and file set as arguments, so the `minimal` and `advanced` expected outputs use one implementation, and `--update-snapshots` accepts `sdk-minimal`. +The snapshot, rather than inline mock-model assertions, owns the minimal scenario's tools and system prompts and reports their complete diff. Snapshot comparison takes its directory and file set as arguments, so the `minimal` and `advanced` expected outputs use one implementation, and `--update-snapshots` accepts `sdk-minimal`. ## Alternatives considered @@ -22,12 +20,12 @@ The mock model no longer asserts the minimal scenario's tools and system prompts **Extend the mock model's inline assertions.** Every new model-visible contribution would need another hand-written expectation, and a failure names one mismatch rather than the whole surface. Tool descriptions would also be duplicated from the composition into the script. -**Rely on the TypeScript SDK snapshot.** Its `persistent-tools` scenario pins the same composition's system prompt, tool schemas, and runtime context, but through replayed model responses and a source or `lib` runtime, in a different required job. It cannot show what the deployed executable's closure assembles for a Python caller. +**Rely on the TypeScript SDK snapshot.** Its `persistent-tools` scenario pins a similar two-tool composition through replayed model responses and a source or `lib` runtime, in a different required job. It cannot show what the deployed executable's shipped profile assembles for a Python caller. ## Consequences A change to the minimal composition's model-visible surface — a system section, a tool, a tool description, or an added user message — now fails `python-runtime` with the exact diff, and landing it means rerunning `--scenario sdk-minimal --update-snapshots` and reviewing that diff. The minimal composition's tool descriptions become reviewed expected output. -Assistant and tool message text is no longer compared, and the runtime-context snapshot is not compared at all. The scenario's own assertions continue to own persistent-shell state, editor output, and the final response; [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) owns the excluded message until its platform difference is resolved. +Assistant and tool message text is not compared. The scenario's own assertions continue to own persistent-shell state, editor output, and the final response; the snapshot owns every model-visible message the profile emits. [AGENTS.md](../../../../AGENTS.md) and [the testing policy](../../../../docs/testing.md) now name both SDKs as independent projections of the agent loop, session lifecycle, and `SessionEventMap`, so a change to any of those carries updating both expected outputs rather than only the one a contributor happens to run. diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md index 5c2b0dd5fc..fbbb3bd6f4 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md @@ -6,15 +6,13 @@ Status: implemented ## 问题 -Python 通道从未比对极简组合实际展示给模型的内容。动态运行时上下文以 user 消息进入历史,因此 mock 模型"system 角色消息等于部署 persona"的断言看不见它;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符、把每个工具 schema 换成其名称。于是 sandbox-policy 的运行时上下文消息一直搭车留在签入的[极简组合](../../../../examples/python-sdk-agent/minimal.cordis.yml)里,而 `python-runtime` 始终是绿的;任何新增系统分段、工具或其他上下文消息的插件都能照此蒙混过关。 +Python 通道需要精确记录独立极简 profile 实际展示给模型的内容。功能性工具断言可以证明执行,但无法发现新增系统分段、工具描述或 user 角色上下文消息;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符,并把每个工具 schema 换成其名称。 ## 决策 -[打包运行时冒烟测试](../../../../scripts/smoke-python-runtime.py)的 `sdk-minimal` 场景会录制 `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`:对该回合的每个模型请求,逐字记录对外公布的工具 schema 与消息列表。system 与 user 消息保留全文,仅将场景的临时目录替换为占位符;assistant 与 tool 消息只保留调用标识,因为它们的 PTY 与文件系统文本在期望输出需要重放的各平台上并不相同。 +[打包运行时冒烟测试](../../../../scripts/smoke-python-runtime.py)的 `sdk-minimal` 场景会启动随附 profile,并录制 `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`:对该回合的每个模型请求,逐字记录对外公布的工具 schema 与消息列表。system 与 user 消息保留全文,仅将场景的临时目录替换为占位符;assistant 与 tool 消息只保留调用标识,因为它们的 PTY 与文件系统文本在各回放平台上并不相同。该 profile 省略动态运行时上下文,因此它发出的每条消息都会参与比对。 -有一条模型可见消息被排除在外:agent loop 的动态运行时上下文快照。同一组合在 macOS 上会发出它,在必需车道所用的 Linux 上不会,因此任何单一期望输出都无法承载它。该差异本身就是缺陷([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))——这份期望输出覆盖其余全部模型可见消息,而不是等它先被修复。 - -mock 模型不再断言极简场景的工具与系统提示词——该面由快照拥有,并给出完整差异而非首个不匹配项。快照比对以目录与文件集合为参数,因此 `minimal` 与 `advanced` 两份期望输出共用一套实现,且 `--update-snapshots` 接受 `sdk-minimal`。 +极简场景的工具与系统提示词由快照拥有,而不是由 mock 模型内联断言;快照会给出其完整差异。快照比对以目录与文件集合为参数,因此 `minimal` 与 `advanced` 两份期望输出共用一套实现,且 `--update-snapshots` 接受 `sdk-minimal`。 ## 曾考虑的替代方案 @@ -22,12 +20,12 @@ mock 模型不再断言极简场景的工具与系统提示词——该面由快 **扩展 mock 模型中的内联断言。** 每新增一项模型可见贡献都要再手写一条期望,且失败只会指出一处不匹配而非整个面。工具描述还会从组合复制进脚本,形成重复。 -**依赖 TypeScript SDK 快照。** 其 `persistent-tools` 场景固定了同一组合的系统提示词、工具 schema 与运行时上下文,但走的是重放的模型响应与 source 或 `lib` 运行时,且位于另一个必需任务中。它无法体现已部署可执行文件的闭包为 Python 调用方组装出什么。 +**依赖 TypeScript SDK 快照。** 其 `persistent-tools` 场景通过重放模型响应与 source 或 `lib` 运行时固定一套相似的双工具组合,且位于另一个必需任务中。它无法体现已部署可执行文件的随附 profile 为 Python 调用方组装出什么。 ## 后果 极简组合模型可见面的改动——系统分段、工具、工具描述或新增的 user 消息——现在会让 `python-runtime` 带着精确差异失败;要让它落地,就必须重新运行 `--scenario sdk-minimal --update-snapshots` 并审阅该差异。极简组合的工具描述由此成为经过审阅的期望输出。 -assistant 与 tool 消息文本不再参与比对,运行时上下文快照则完全不参与比对。持久 shell 状态、编辑器输出与最终响应仍由该场景自身的断言拥有;被排除的那条消息由 [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) 负责,直到其平台差异得到解决。 +assistant 与 tool 消息文本不参与比对。持久 shell 状态、编辑器输出与最终响应仍由该场景自身的断言拥有;快照负责该 profile 发出的每条模型可见消息。 [AGENTS.md](../../../../AGENTS.md) 与[测试政策](../../../../docs/testing.zh.md)现已点明两个 SDK 都是 agent loop、会话生命周期与 `SessionEventMap` 的独立投影,因此改动其中任何一项都要连带更新两侧的期望输出,而不只是贡献者恰好会运行的那一侧。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c23418172b..f73476f371 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: b64861ce68b4c6de8edc3142fb95d83f1d395d15 -README.zh.md: a42a1b0539703dc03adf0ac53e8b5819940429a2 +README.md: ff0efb03d8a5d6da747f1b5bc0d05361a6c8a567 +README.zh.md: 8e9c122afa87525c99e8045f220b96b4511c04fe diff --git a/apps/cli/README.md b/apps/cli/README.md index b64861ce68..ff0efb03d8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command and launches its `sdk` profile. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. +The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. ## Entry modes @@ -12,10 +12,11 @@ The `dsh` command is the sole supported Node application launcher: profiles are | `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. | | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. | | `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. | +| `dsh --profile sdk-minimal` | Serve SDK clients with the standalone minimal agent tree. | | `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. ## App arguments @@ -38,7 +39,7 @@ The tree composes over an empty root: - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` - then `--patch` overlays -Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. +Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a42a1b0539..8e9c122afa 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。Python 运行时 wheel 会打包同一个命令,并启动其 `sdk` profile。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。Python 运行时 wheel 会打包同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 ## 入口模式 @@ -12,10 +12,11 @@ | `dsh --profile acp` | 通过 ACP stdio 为自动化 client 提供服务,直至断开连接。 | | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 | | `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK client 提供服务,直至关闭或断开连接。 | +| `dsh --profile sdk-minimal` | 以独立极简 agent 配置树为 SDK client 提供服务。 | | `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 ## 应用参数 @@ -40,7 +41,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` - `--patch` 指定的覆盖层 -`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 +`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9e119a6100..9ec9a9a22d 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -3,7 +3,7 @@ # DSH Base Composition -The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user's profile layer patch over it. +The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; their mode bundles and user layers patch over it, while sdk-minimal owns a separate standalone tree. ```mermaid flowchart LR diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index f432cc953b..96e7b4c7b0 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 2692731ecdfb4a3f798ca0506954289f2c9bdca8 -README.zh.md: 0a2c0a14151f7b774ed00aab808c7313716c131a +README.md: 33de399dc4b2b8e67ed24ed046fcc0da2ff0a7ac +README.zh.md: 0f20245f318e31159780d29cca955fc85a5b5481 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 2692731ecd..33de399dc4 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,9 +8,9 @@ This reference defines the profile, web-alias, plugin-management, and config-dum `dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules`. Plain Node installations place one healed symlink there per dependency-closure package. A pkg executable instead places a real ESM proxy that mirrors explicit exports and re-exports the virtual package URL, because operating-system symlinks cannot enter pkg's `/snapshot` filesystem. -The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. +The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. ### App arguments @@ -27,6 +27,7 @@ The shipped apps own these command lines: | `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` | | `headless` | the task text, as the positional argument | | `sdk` | no options; stdio carries the JSON-RPC protocol | +| `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol | | `acp` | no options; stdio carries Agent Client Protocol | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -80,9 +81,9 @@ The production Web runner needs built package and frontend artifacts (`pnpm run Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. +The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its local filesystem and sandbox-policy root but intentionally omits instruction discovery and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. -New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. +New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 0a2c0a1415..0f20245f31 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -8,9 +8,9 @@ `dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。普通 Node 安装会为依赖闭包中的每个包放置并修复一个符号链接。pkg 可执行程序则放置真实 ESM 代理,镜像显式 exports 并重新导出虚拟包 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统。 -`web`、`headless`、`sdk` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 ### 应用参数 @@ -27,6 +27,7 @@ | `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` | | `headless` | 任务文本,作为位置参数 | | `sdk` | 无选项;stdio 携带 JSON-RPC 协议 | +| `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 | | `acp` | 无选项;stdio 携带 Agent Client Protocol | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -80,9 +81,9 @@ dsh web --help 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 -所有模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 +基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为本地文件系统与沙箱策略根目录,但刻意省略指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 -新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 +基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 `DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 1c77b1ff8a..30058a380f 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -145,8 +145,8 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order (the base bundle gates the shell stacks by - * platform on its own rows), the profile's user layer, the home-level user + * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's + * platform-gated shell rows), the profile's user layer, the home-level user * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply * to every profile, so it outranks the per-profile layer), `--patch` overlays, * then the telemetry switch. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index fe84ec6adf..82e57fd2f7 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 362a3758fabdb67ea883bcf84ea5df4cf4e77b0f -architecture.zh.md: 421c38ad5b90db7b7ac1b5fe6bb10ccc0793feb5 +architecture.md: add615c252948adab8db7dec7059f94b8f45e52c +architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748 diff --git a/docs/architecture.md b/docs/architecture.md index 362a3758fa..add615c252 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,17 +16,17 @@ There is no privileged core to patch: you extend dsh by mounting a plugin beside A running `dsh` is a plugin tree composed at boot from ordered layers. -A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web`, `headless`, `sdk`, and `acp` ship as templates. +A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` ship as templates. A **bundle** is a distribution format for Cordis config rows and the code they mount, so whatever it inserts stays patchable by the layers above it. Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile` lists a profile's bundles, and `dsh.bundle` points at a bundle's patch file. -[`dsh-base`](../packages/bundle/base/README.md) is the first layer of every profile: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application, [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server, [`dsh-sdk-app`](../packages/bundle/sdk-app/README.md) adds the SDK JSON-RPC server, and [`dsh-acp-app`](../packages/bundle/acp-app/README.md) adds the automation-only ACP server. +[`dsh-base`](../packages/bundle/base/README.md) is the shared first layer of the `web`, `headless`, `sdk`, and `acp` profiles: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application, [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server, [`dsh-sdk-app`](../packages/bundle/sdk-app/README.md) adds the SDK JSON-RPC server, and [`dsh-acp-app`](../packages/bundle/acp-app/README.md) adds the automation-only ACP server. [`dsh-sdk-minimal`](../packages/bundle/sdk-minimal/README.md) is the deliberate exception: one bundle owns its complete explicit SDK tree and does not apply `dsh-base`. Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows. -Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle. +Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle. To see the tree your machine actually boots: @@ -40,11 +40,11 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof ## Application launch -Every supported Node application starts at the `dsh` CLI with a named profile. The shipped applications are `dsh web` (the deliberate alias for `--profile web`), `dsh --profile headless`, `dsh --profile sdk`, and `dsh --profile acp`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. +Every supported Node application starts at the `dsh` CLI with a named profile. The shipped applications are `dsh web` (the deliberate alias for `--profile web`), `dsh --profile headless`, `dsh --profile sdk`, `dsh --profile sdk-minimal`, and `dsh --profile acp`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree. Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. -The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. +The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. ## Core packages diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 421c38ad5b..48baf14d6a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -16,17 +16,17 @@ 运行中的 `dsh` 是一棵插件树,由启动时按序叠加的各层组合而成。 -**profile** 是存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 `cordis.patch.yml`。`web`、`headless`、`sdk` 和 `acp` 作为模板随发行版交付。 +**profile** 是存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 `cordis.patch.yml`。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` 作为模板随发行版交付。 **组合包**是 Cordis 配置项及其挂载代码的分发格式,因此它插入的内容始终可被其上各层 patch。 两者都在各自的 `package.json` 中通过 `dsh` 字段声明自己:`dsh.profile` 列出一个 profile 的组合包,`dsh.bundle` 指向一个组合包的 patch 文件。 -[`dsh-base`](../packages/bundle/base/README.zh.md) 是每个 profile 的第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。[`dsh-web-app`](../packages/bundle/web-app/README.zh.md) 增加浏览器应用,[`dsh-headless`](../packages/bundle/headless/README.zh.md) 增加不带服务器的一次性运行器,[`dsh-sdk-app`](../packages/bundle/sdk-app/README.zh.md) 增加 SDK JSON-RPC 服务器,[`dsh-acp-app`](../packages/bundle/acp-app/README.zh.md) 增加仅用于自动化的 ACP 服务器。 +[`dsh-base`](../packages/bundle/base/README.zh.md) 是 `web`、`headless`、`sdk` 与 `acp` profile 的共享第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。[`dsh-web-app`](../packages/bundle/web-app/README.zh.md) 增加浏览器应用,[`dsh-headless`](../packages/bundle/headless/README.zh.md) 增加不带服务器的一次性运行器,[`dsh-sdk-app`](../packages/bundle/sdk-app/README.zh.md) 增加 SDK JSON-RPC 服务器,[`dsh-acp-app`](../packages/bundle/acp-app/README.zh.md) 增加仅用于自动化的 ACP 服务器。[`dsh-sdk-minimal`](../packages/bundle/sdk-minimal/README.zh.md) 是刻意保留的例外:一个组合包拥有完整的显式 SDK 配置树,不应用 `dsh-base`。 各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。 -自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。 +自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。 要查看你的机器实际启动的配置树: @@ -40,11 +40,11 @@ dsh --profile web --dump-config ## 应用启动 -所有受支持的 Node 应用都从 `dsh` CLI 与具名 profile 启动。随附应用是 `dsh web`(刻意为 `--profile web` 保留的别名)、`dsh --profile headless`、`dsh --profile sdk` 与 `dsh --profile acp`。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。 +所有受支持的 Node 应用都从 `dsh` CLI 与具名 profile 启动。随附应用是 `dsh web`(刻意为 `--profile web` 保留的别名)、`dsh --profile headless`、`dsh --profile sdk`、`dsh --profile sdk-minimal` 与 `dsh --profile acp`。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。 Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 -Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端以显式 Harness home 启动 `dsh --profile sdk`。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 +Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 ## 核心包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4998dcd505..eabedcfe3c 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: cb156837fa0768dfa74d9b351cc3e362449ca248 -config-catalog.zh.md: 151a72190332a400b643111495d36e2d7f205d93 +config-catalog.md: 7f7e5d3c58953ea50c43eed0d903f0d5469e7849 +config-catalog.zh.md: f9ab7a8537e29cb0e74e05e74b4a7890d146c28b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb156837fa..7f7e5d3c58 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1681,6 +1681,22 @@ Depends on: [`SandboxMode`](subsystems/sandbox.md) Source: [`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) + + +## `@deepseek-ai/dsh-sdk-app` + +Requires: `cmdlineArgs` + +```ts config-catalog +/** SDK stdio startup configuration. */ +export interface Config { + /** Profile name rendered in help and diagnostics (default `sdk`). */ + profile?: string +} +``` + +Source: [`packages/bundle/sdk-app/src/index.ts:23`](../packages/bundle/sdk-app/src/index.ts) + ## `@deepseek-ai/dsh-sdk-jsonrpc-server` @@ -3330,7 +3346,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) -- `@deepseek-ai/dsh-sdk-app` — requires `cmdlineArgs` ([`packages/bundle/sdk-app/src/index.ts`](../packages/bundle/sdk-app/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-log-export` — requires `commands` ([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) @@ -3400,6 +3415,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) +- `@deepseek-ai/dsh-sdk-minimal` ([`packages/bundle/sdk-minimal/src/index.ts`](../packages/bundle/sdk-minimal/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol` ([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 151a721903..f9ab7a8537 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1683,6 +1683,22 @@ export interface Config { 来源:[`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) + + +## `@deepseek-ai/dsh-sdk-app` + +需要:`cmdlineArgs` + +```ts config-catalog +/** SDK stdio startup configuration. */ +export interface Config { + /** Profile name rendered in help and diagnostics (default `sdk`). */ + profile?: string +} +``` + +来源:[`packages/bundle/sdk-app/src/index.ts:23`](../packages/bundle/sdk-app/src/index.ts) + ## `@deepseek-ai/dsh-sdk-jsonrpc-server` @@ -3332,7 +3348,6 @@ export interface Config { - `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) -- `@deepseek-ai/dsh-sdk-app` — 需要 `cmdlineArgs`([`packages/bundle/sdk-app/src/index.ts`](../packages/bundle/sdk-app/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-log-export` — 需要 `commands`([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) @@ -3401,6 +3416,7 @@ export interface Config { - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) +- `@deepseek-ai/dsh-sdk-minimal`([`packages/bundle/sdk-minimal/src/index.ts`](../packages/bundle/sdk-minimal/src/index.ts)) - `@deepseek-ai/dsh-sdk-protocol`([`packages/sdk/protocol/src/index.ts`](../packages/sdk/protocol/src/index.ts)) - `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e5f37e4a8c..a7edea20b2 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: d401df81c59de27a0171750d92031c40939c6564 -module-graph.zh.md: 8186217711a2ca320017dc0473227f7a9cf59b1a +module-graph.md: a2497ed002c6eec55ec35e1d7e9953feb2ed0ee5 +module-graph.zh.md: 230d67f4c07f9715d4b5157c1539edd09f9221c3 diff --git a/docs/module-graph.md b/docs/module-graph.md index d401df81c5..a2497ed002 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -123,6 +123,7 @@ flowchart TD pkg_base["base"] pkg_headless["headless"] pkg_sdk_app["sdk-app"] + pkg_sdk_minimal["sdk-minimal"] pkg_web_app["web-app"] end subgraph group_client["packages/client"] @@ -366,6 +367,7 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_sdk_minimal --> pkg_invariants pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_renderer --> pkg_invariants @@ -1656,6 +1658,7 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8186217711..230d67f4c0 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -125,6 +125,7 @@ flowchart TD pkg_base["base"] pkg_headless["headless"] pkg_sdk_app["sdk-app"] + pkg_sdk_minimal["sdk-minimal"] pkg_web_app["web-app"] end subgraph group_client["packages/client"] @@ -368,6 +369,7 @@ flowchart TD pkg_acp_app --> pkg_invariants pkg_base --> pkg_invariants pkg_sdk_app --> pkg_invariants + pkg_sdk_minimal --> pkg_invariants pkg_client_store --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_renderer --> pkg_invariants @@ -1658,6 +1660,7 @@ flowchart TD | [`acp-app`](../packages/bundle/acp-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-store`](../packages/client/store) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 69e9720f11..88d467f519 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md -python-sdk.md: 98d7f8160dee0874338c1d914796dcfb72400a48 -python-sdk.zh.md: 8a0417b24be859b8d327a8650ce1db23adb4dba2 +python-sdk.md: 2cf6acbd6718be560ec4365b356c031b17fb070e +python-sdk.zh.md: 2c53886ba3409e6fc21ec57fc79b81bf6907349c diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 98d7f8160d..2cf6acbd67 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -2,7 +2,7 @@ English | [中文](python-sdk.zh.md) -This tutorial installs the published Python SDK, runs the checked-in minimal profile overlay, and shows how to customize the same `dsh` profile from your own program. +This tutorial installs the published Python SDK, runs the shipped standalone minimal profile, and shows how to customize the same `dsh` profile from your own program. ## Prerequisites @@ -43,7 +43,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -The script prints the final assistant response. The selected home receives the generated `sdk` profile, settings, credentials if you add them, installed plugins, and Zstandard session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. +The script prints the final assistant response. The selected home receives the generated `sdk-minimal` profile, installed plugins, and uncompressed JSONL session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. ## Use the SDK in your program @@ -54,16 +54,14 @@ from deepseek_harness import DeepSeekHarness workspace = Path("/absolute/path/to/disposable-workspace").resolve() dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() -patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() - with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), dsh_home=str(dsh_home), - profile="sdk", - patches=(str(patch),), + profile="sdk-minimal", + env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -73,7 +71,7 @@ with DeepSeekHarness( print(result.final_response) ``` -The SDK starts the bundled `dsh --profile sdk` process lazily and reuses it until context-manager exit. The profile, its persistent patch, the home patch, and the ordered `patches` tuple form the application configuration. There is no separate Python runtime bin or complete-config option. +The SDK starts the bundled `dsh --profile sdk-minimal` process lazily and reuses it until context-manager exit. The profile, its persistent patch, the home patch, and any ordered `patches` tuple form the application configuration. There is no separate Python runtime bin or complete-config option. ## Install or define plugins @@ -81,15 +79,15 @@ Use `dsh plugin` for dependencies and bundle layers that should persist in this ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh --profile sdk --dump-default-config >/dev/null -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh --profile sdk-minimal --dump-default-config >/dev/null +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -The first command initializes the shipped SDK profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. +The first command initializes the shipped standalone profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. -## Understand the minimal overlay +## Understand the minimal profile | Property | Value | |---|---| @@ -98,11 +96,13 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe | Model-facing tools | Persistent `bash` and `str_replace_editor` only | | Bash timeout | 300 seconds | | Editor output limit | 16,000 characters | -| Context compaction | Disabled | -| Session persistence | Zstandard JSONL under `/sessions` | +| Runtime context and compaction | Absent | +| Session persistence | Uncompressed JSONL under `/sessions` | -The overlay allowlists persistent Bash and the editor for every SDK-created root agent, so later base-profile tools cannot appear implicitly. It suppresses unrelated prompt sections and runtime-context messages, disables local instruction discovery and compaction, and retains the SDK application's protocol, persistence, policy, settings, credentials, and providers. Persistent Bash and the editor can modify any path visible to the runtime, so use a disposable checkout or container. The PTY implementation makes this example POSIX-only. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so persistent Bash and the editor can modify any path visible to the runtime; use a disposable checkout or container. The PTY implementation makes this example POSIX-only. + +The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. Use a fresh home when profiles, plugins, credentials, settings, and sessions must be isolated. Use a fresh session id for independent work; reuse a harness, home, and id only to continue the same durable conversation and session-owned resources. -The [example reference](../../../examples/python-sdk-agent/README.md) owns the checked-in overlay. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, and low-level behavior; the [dsh CLI reference](../../../apps/cli/reference/README.md) covers profile layering. +The [bundle reference](../../../packages/bundle/sdk-minimal/README.md) owns the exact tree, and the [example reference](../../../examples/python-sdk-agent/README.md) owns the runnable program. The [Python SDK reference](../../../python/sdk/README.md) covers lifecycle, results, notifications, and low-level behavior; the [dsh CLI reference](../../../apps/cli/reference/README.md) covers profile layering. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 8a0417b24b..2c53886ba3 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -2,7 +2,7 @@ [English](python-sdk.md) | 中文 -本教程安装已发布的 Python SDK,运行检入的极简 profile overlay,并说明如何从自己的程序自定义同一个 `dsh` profile。 +本教程安装已发布的 Python SDK,运行随附的独立极简 profile,并说明如何从自己的程序自定义同一个 `dsh` profile。 ## 前置条件 @@ -43,7 +43,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk` profile、设置、调用方添加的凭据、已安装插件,以及 `sessions/` 下的 Zstandard 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 +脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk-minimal` profile、已安装插件,以及 `sessions/` 下的未压缩 JSONL 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 ## 在程序中使用 SDK @@ -54,16 +54,14 @@ from deepseek_harness import DeepSeekHarness workspace = Path("/absolute/path/to/disposable-workspace").resolve() dsh_home = Path("/absolute/path/to/example-dsh-home").resolve() -patch = Path("examples/python-sdk-agent/minimal.patch.yml").resolve() - with DeepSeekHarness( provider="deepseek-official", model="deepseek-v4-flash", max_tokens=49_152, cwd=str(workspace), dsh_home=str(dsh_home), - profile="sdk", - patches=(str(patch),), + profile="sdk-minimal", + env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", @@ -73,7 +71,7 @@ with DeepSeekHarness( print(result.final_response) ``` -SDK 会延迟启动内置的 `dsh --profile sdk` 进程,并复用到上下文管理器退出。Profile、其持久 patch、home patch 与有序 `patches` tuple 共同组成应用配置。不存在独立 Python 运行时 bin 或完整配置选项。 +SDK 会延迟启动内置的 `dsh --profile sdk-minimal` 进程,并复用到上下文管理器退出。Profile、其持久 patch、home patch 与任何有序 `patches` tuple 共同组成应用配置。不存在独立 Python 运行时 bin 或完整配置选项。 ## 安装或定义插件 @@ -81,15 +79,15 @@ SDK 会延迟启动内置的 `dsh --profile sdk` 进程,并复用到上下文 ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh --profile sdk --dump-default-config >/dev/null -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh --profile sdk-minimal --dump-default-config >/dev/null +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -第一个命令初始化随附的 SDK profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 +第一个命令初始化随附的独立 profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 -## 理解极简 overlay +## 理解极简 profile | 属性 | 值 | |---|---| @@ -98,11 +96,13 @@ dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle | 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | | Bash 超时 | 300 秒 | | Editor 输出上限 | 16,000 字符 | -| 上下文压缩 | 禁用 | -| 会话持久化 | `/sessions` 下的 Zstandard JSONL | +| 运行时上下文与 compaction | 不存在 | +| 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 overlay 会为每个由 SDK 创建的根 agent allowlist 持久 Bash 与 editor,因此基础 profile 以后新增的工具不会隐式出现。它会抑制无关提示词段与运行时上下文消息,停用本地指令发现与 compaction,并保留 SDK 应用的协议、持久化、策略、settings、credentials 与 provider。持久 Bash 与 editor 可以修改运行时可见的任何路径,因此应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此持久 Bash 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 + +已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 需要隔离 profile、插件、凭据、设置与会话时,应使用新的 home。独立工作应使用新的 session id;只有继续同一段持久对话和会话资源时,才同时复用 harness、home 与 id。 -[示例参考](../../../examples/python-sdk-agent/README.zh.md)定义检入 overlay。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知与底层行为;[dsh CLI 参考](../../../apps/cli/reference/README.zh.md)介绍 profile 分层。 +[组合包参考](../../../packages/bundle/sdk-minimal/README.zh.md)定义确切配置树,[示例参考](../../../examples/python-sdk-agent/README.zh.md)定义可运行程序。[Python SDK 参考](../../../python/sdk/README.zh.md)介绍生命周期、结果、通知与底层行为;[dsh CLI 参考](../../../apps/cli/reference/README.zh.md)介绍 profile 分层。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index f0f1ef1980..0c3a5fe203 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-agent/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 examples/python-sdk-agent/README.md -README.md: b1718bfa3fb6ea4cb241bb4211b58a21bacc17a2 -README.zh.md: dbc4053093f66a60a18ab02bb727611a279fe03c +README.md: 5bb958c3e9202642926a0186d055615a895d9586 +README.zh.md: 97bfe9e1d19a7cd37ed41fccc64340dc054ecec7 diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index b1718bfa3f..5bb958c3e9 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runnable Python SDK example over the sole application launcher, `dsh --profile sdk`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, permissions, and plugins. +Runnable Python SDK example over the sole application launcher, `dsh --profile sdk-minimal`. The Python client owns JSON-RPC stdio; the profile owns the agent composition, persistence, execution policy, and plugins. ## Run the minimal agent @@ -17,14 +17,14 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` and `--profile` override their script defaults. The selected home stores the generated profile and Zstandard session logs under `sessions/`; the script never reads `~/.dsh` implicitly. +Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` overrides the model and passes the same value to the profile-owned adapter catalog; `--profile` can select another SDK-serving profile. The selected home stores the generated `sdk-minimal` profile and uncompressed JSONL session logs under `sessions/`; the script never reads `~/.dsh` implicitly. -[`minimal.patch.yml`](minimal.patch.yml) is an ordered overlay on the shipped SDK profile. Its root-agent tool allowlist exposes exactly: +The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../packages/bundle/sdk-minimal/README.md) is the complete explicit Cordis tree for this mode. It exposes exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -The allowlist excludes every other current or later global tool without requiring a disable entry for each base row. A complete deployment persona suppresses unrelated tool-guidance sections; runtime-context messages, local instruction discovery, compaction, and the conflicting one-shot Bash row are disabled separately. The overlay inserts the local PTY and persistent Bash providers and sets the editor output limit to 16,000 characters. Other SDK-profile services remain mounted, including persistence, policy, settings, credentials, and providers. +The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, local instruction discovery, compaction, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. @@ -34,9 +34,11 @@ Use the runtime wheel's `dsh` command against the same explicit home for persist ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -The Python call can also pass additional absolute patch paths in `patches=(...)`; later files win. A selected profile must retain `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Complete standalone Cordis files in this directory remain test fixtures for lower-level composition coverage; they are not Python SDK launch interfaces. +Use `sdk-minimal` in that command to extend this example, or `sdk` to extend the full base-backed SDK profile. The Python call can also pass additional absolute patch paths in `patches=(...)`; later files win. A selected profile must retain `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. The example accepts no complete Cordis file or arbitrary process argv. + +The same runtime wheel packages the `web` profile and its frontend assets for direct CLI use: `dsh web` starts that separate application. A Python SDK client cannot select `web` because it has no JSON-RPC server row. See the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) and [SDK reference](../../python/sdk/README.md). diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index dbc4053093..97bfe9e1d1 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -基于唯一应用启动器 `dsh --profile sdk` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent 组合、持久化、权限与插件。 +基于唯一应用启动器 `dsh --profile sdk-minimal` 的可运行 Python SDK 示例。Python 客户端负责 JSON-RPC stdio;profile 负责 agent 组合、持久化、执行策略与插件。 ## 运行极简 agent @@ -17,14 +17,14 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 与 `--profile` 会覆盖脚本默认值。所选 home 保存生成的 profile,并在 `sessions/` 下保存 Zstandard 会话日志;脚本绝不会隐式读取 `~/.dsh`。 +兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 会覆盖模型,并将同一值传给 profile 自有的适配器目录;`--profile` 可以选择另一个提供 SDK 服务的 profile。所选 home 保存生成的 `sdk-minimal` profile,并在 `sessions/` 下保存未压缩 JSONL 会话日志;脚本绝不会隐式读取 `~/.dsh`。 -[`minimal.patch.yml`](minimal.patch.yml) 是随附 SDK profile 上的有序 overlay。其根 agent 工具 allow 列表只暴露: +随附的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../packages/bundle/sdk-minimal/README.zh.md)是该模式完整且显式的 Cordis 配置树。它只暴露: - agent 所有的持久 `bash` - 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -Allow 列表会排除当前及以后出现的其他所有全局工具,无需为每个基础配置项添加 disable。完整部署 persona 会抑制无关工具的引导段;运行时上下文消息、本地指令发现、compaction 与存在名称冲突的单次 Bash 配置项会分别停用。该 overlay 会插入本地 PTY 与持久 Bash provider,并将 editor 输出上限设为 16,000 字符。其他 SDK profile 服务仍保持挂载,包括持久化、策略、settings、credentials 与 provider。 +该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、本地指令发现、compaction、settings、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。 此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 @@ -34,9 +34,11 @@ Allow 列表会排除当前及以后出现的其他所有全局工具,无需 ```sh export DSH_HOME=/absolute/path/to/example-dsh-home -dsh plugin --profile sdk add file:/absolute/path/to/my-plugin-bundle +dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` -Python 调用也可以在 `patches=(...)` 中传入更多绝对 patch 路径;后面的文件优先。所选 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项。本目录中的完整独立 Cordis 文件仍作为底层组合测试 fixture;它们不是 Python SDK 启动接口。 +在该命令中使用 `sdk-minimal` 可扩展本示例,使用 `sdk` 则扩展基于完整 base 的 SDK profile。Python 调用也可以在 `patches=(...)` 中传入更多绝对 patch 路径;后面的文件优先。所选 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项。该示例不接受完整 Cordis 文件或任意进程 argv。 + +同一个运行时 wheel 还为直接 CLI 使用打包 `web` profile 及其前端产物:`dsh web` 会启动这个独立应用。Python SDK client 不能选择 `web`,因为其中没有 JSON-RPC server 配置项。 另见 [Python SDK 教程](../../docs/user/guide/python-sdk.zh.md)与 [SDK 参考](../../python/sdk/README.zh.md)。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ae60e877c0..7f7c3badcf 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: 0adcb0ac20516b1eea97792a11471a383a3ab429 -README.zh.md: 1da1725b1e0681f83f2c82b82cb9bbaac8ea90cb +README.md: a7c6272fd96fdf24b4f21bb8b60c087af4a4dc96 +README.zh.md: 09a63765414ba76d78a7c88f3777f63eecd835f7 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 0adcb0ac20..a7c6272fd9 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](.. ## Profiles -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. A complete matching generation returns without acquiring the writer lock. A missing or stale entry acquires the cross-process lock, rechecks the full generation, and repairs it without exposing partial proxies; either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. +A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves available explicit exports directly from each installed manifest with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Export targets absent from an installed package remain unavailable without blocking its other exports; malformed export maps fail startup. An executable-only or declaration-only package with no module entry produces no proxy. A complete matching generation returns without acquiring the writer lock. A missing or stale entry acquires the cross-process lock, rechecks the full generation, and repairs it without exposing partial proxies; either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`sdk-minimal`/`acp` with startup-only patches; `sdk-minimal` lists only its standalone bundle, while the other templates retain their base-plus-mode stacks. Other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged. User-level machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 1da1725b1e..09a6376541 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 ## Profiles -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。完整且匹配的 generation 不会获取写入锁。缺失或过期的配置项会获取跨进程锁、重新检查完整 generation,并在不暴露半成品代理的前提下修复;两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 +profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则直接从每个已安装 manifest 中按 Node ESM import 条件解析实际存在的显式 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。安装包中不存在的 export 目标保持不可用,但不阻塞其他 exports;格式错误的 exports map 会导致启动失败。只有可执行入口或类型声明入口而没有模块入口的包不会生成代理。完整且匹配的 generation 不会获取写入锁。缺失或过期的配置项会获取跨进程锁、重新检查完整 generation,并在不暴露半成品代理的前提下修复;两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`sdk-minimal`/`acp`;`sdk-minimal` 只列出自己的独立组合包,其他模板保留 base 加模式层的组合。其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。 用户级的机器本地偏好同样位于 harness home 中: diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 1ca618d528..6ad818ee64 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/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/bundle/README.md -README.md: 44cf46a217e078e4d71d838afb6525f2bc908bd2 -README.zh.md: f2987b72c526698e2df949e2edfe55e1375f2d8e +README.md: c0ea5be0c6f1b2457166b24a1718f6ab3aa0ffe6 +README.zh.md: 6c3520668d8f83cfbdc75652fac81f29d9bb810d diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 44cf46a217..c0ea5be0c6 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -8,7 +8,7 @@ The manifest declaration, not this directory, defines Bundle identity. Domain pa | Package | Role | ctx key | |---|---|---| -| [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) | +| [`base/`](base/README.md) | The shared dsh core applied first by base-backed profiles | — (patch only) | | [`acp-app/`](acp-app/README.md) | Automation-only ACP stdio application over base | mounts the ACP bridge | | [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows | | [`headless/`](headless/README.md) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` | diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index f2987b72c5..6c3520668d 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -8,7 +8,7 @@ Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包 | 包 | 职责 | ctx key | |---|---|---| -| [`base/`](base/README.zh.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) | +| [`base/`](base/README.zh.md) | 基于 base 的 profile 最先应用的共享 dsh 核心 | —(仅 patch) | | [`acp-app/`](acp-app/README.zh.md) | 运行在 base 之上的 automation-only ACP stdio 应用 | 挂载 ACP bridge | | [`web-app/`](web-app/README.zh.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 | | [`headless/`](headless/README.zh.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` | diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index bfcd5c6a66..0ab5140ece 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 74f1288b46dd20643a494acb1829dbe38c367622 -README.zh.md: dda46a89f3c2b161d7358109e4317a976eaa65c8 +README.md: 9fb1264f39aee3f3961ff4fd3c07f35b0b7cefdf +README.zh.md: 844254acc1f349a9debf38494e8d97f5f7e2d00b diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 74f1288b46..9fb1264f39 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider Bundle](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider, the Claude Agent SDK, nor the Codex wrapper and platform payloads. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of each base-backed profile's `dsh.profile.bundles` list. The standalone [`sdk-minimal`](../sdk-minimal/README.md) profile deliberately does not include this bundle. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider Bundle](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider, the Claude Agent SDK, nor the Codex wrapper and platform payloads. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. The base module-HMR row is disabled. A profile with a tested source-module reload lifecycle enables that row explicitly; `patchReload: live` config watching is independent and uses the launcher's watch-only fallback while module HMR remains disabled. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index dda46a89f3..844254acc1 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.zh.md) 选择、工具、持久化、策略、settings/credentials、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装任一[产品 provider Bundle](../../subagent/README.zh.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider、Claude Agent SDK,也不包含 Codex wrapper 及其平台载荷。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.zh.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.zh.md) 选择、工具、持久化、策略、settings/credentials、遥测与核心 spawn/fork subagent provider——作为每个基于 base 的 profile 的 `dsh.profile.bundles` 列表中的第一层。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) profile 刻意不包含本组合包。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装任一[产品 provider Bundle](../../subagent/README.zh.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider、Claude Agent SDK,也不包含 Codex wrapper 及其平台载荷。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.zh.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 base 的模块 HMR 配置项默认禁用。具有经过验证的源码模块重载生命周期的 profile 必须显式启用该配置项;`patchReload: live` 配置监视与之独立,在模块 HMR 保持禁用时使用启动器的仅监视 fallback。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e7e963e59f..65da35f554 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -1,4 +1,4 @@ -# The dsh-base bundle patch: the shared core of every dsh profile, applied as +# The dsh-base bundle patch: the shared core of each base-backed profile, applied as # ONE insert over the empty profile root. Later bundle patches and the user's # profile cordis.patch.yml address these rows by id, with the last write # winning per row. diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 2d0977a727..80f33257db 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-base", - "description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root", + "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index 51cf2a832d..715a58381e 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/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/bundle/sdk-minimal/README.md -README.md: 5bc60991624ab52b3d55acac4851b6af2b198218 -README.zh.md: 7e11140bcb8a81cf3e22db88530bfcb364436f96 +README.md: 7f37d0cb4996a9c48e4bcb934565ff2865f5e7e3 +README.zh.md: 3e2c5b5f66314e8dc2da9622bbd1937d85d98490 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 5bc6099162..7f37d0cb49 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -26,6 +26,6 @@ Stable for a fixed persona, platform, provider, model, and bundle patch stack. P ## Known Limitations and Deferred Work -- **The profile is POSIX-only** — this layer uses a Bash PTY; Windows support belongs to the platform runtime layer above it. +- **The profile is POSIX-only** — this composition uses a Bash PTY; a Windows profile must select a PowerShell terminal and tool instead. - **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. - **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 7e11140bcb..3e2c5b5f66 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -26,6 +26,6 @@ ## 已知限制与待办工作 -- **该 profile 仅支持 POSIX** — 此层使用 Bash PTY;Windows 支持属于其上的平台运行时层。 +- **该 profile 仅支持 POSIX** — 此组合使用 Bash PTY;Windows profile 必须改为选择 PowerShell 终端与工具。 - **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、权限策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 - **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index c3e1d67e92..c0c43e838d 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/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/examples/README.md -README.md: 1193ae404bb7f1a69636f45ff36eee82ec648b2c -README.zh.md: 935bbd1329082f857a692df913a89e7007054b63 +README.md: 2d17e60ed990c8cb77ebfd5823ba2f67e63e8837 +README.zh.md: deea149a11dc70a329bb27c6d851d62d46c6d16e diff --git a/packages/examples/README.md b/packages/examples/README.md index 1193ae404b..2d17e60ed9 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -8,7 +8,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling |---|---|---| | [`agent-spine-demo/`](agent-spine-demo/README.md) | `@deepseek-ai/dsh-agent-spine-demo` | Reusable agent-spine bundle | -`agent-spine-demo` is the shared bundle. Product SDK, ACP, and one-shot execution belong to `dsh --profile sdk`, `dsh --profile acp`, and `dsh --profile headless`; no package in this directory provides an application entry. +`agent-spine-demo` is the shared bundle. Product SDK, ACP, and one-shot execution belong to `dsh --profile sdk` / `dsh --profile sdk-minimal`, `dsh --profile acp`, and `dsh --profile headless`; no package in this directory provides an application entry. These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 935bbd1329..deea149a11 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -8,7 +8,7 @@ |---|---|---| | [`agent-spine-demo/`](agent-spine-demo/README.zh.md) | `@deepseek-ai/dsh-agent-spine-demo` | 可复用的 agent-spine(智能体主干)组合包 | -`agent-spine-demo` 是共享组合包。产品 SDK、ACP 与一次性执行分别由 `dsh --profile sdk`、`dsh --profile acp` 和 `dsh --profile headless` 提供;本目录没有任何包提供应用入口。 +`agent-spine-demo` 是共享组合包。产品 SDK、ACP 与一次性执行分别由 `dsh --profile sdk`/`dsh --profile sdk-minimal`、`dsh --profile acp` 和 `dsh --profile headless` 提供;本目录没有任何包提供应用入口。 这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index fee9373a2d..1076f068de 100644 --- a/python/README.i18n.yaml +++ b/python/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 python/README.md -README.md: 7cb822e475acaa36a2a1637b0b4830e7dcabcb7a -README.zh.md: e571b936dad5d14db834fcabed363b5b7c199807 +README.md: d82195fa8a3f39bea129030b2eaf7d65536041e0 +README.zh.md: cb0fb28fff7d2442c948205b07c457243fb85022 diff --git a/python/README.md b/python/README.md index 7cb822e475..d82195fa8a 100644 --- a/python/README.md +++ b/python/README.md @@ -13,7 +13,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com ## Behavior -The SDK starts the matching bundled `dsh --profile sdk` runtime unless the caller selects another `dsh` executable or profile. Every launch requires an explicitly selected Harness home; Python never silently reads `~/.dsh`. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own runtime selection, profiles, patches, and external plugin management. +The SDK starts the matching bundled `dsh --profile sdk` runtime unless the caller selects another `dsh` executable or profile. The runnable minimal example selects the shipped standalone `sdk-minimal` profile; the same runtime also packages `dsh web` and its frontend assets for separate CLI use. Every launch requires an explicitly selected Harness home; Python never silently reads `~/.dsh`. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own runtime selection, profiles, patches, and external plugin management. ## Contributor workflows diff --git a/python/README.zh.md b/python/README.zh.md index e571b936da..cb0fb28fff 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -13,7 +13,7 @@ ## 行为 -除非调用方选择另一个 `dsh` 可执行程序或 profile,否则 SDK 会启动匹配的内置 `dsh --profile sdk` 运行时。每次启动都要求显式选择 Harness home;Python 绝不会静默读取 `~/.dsh`。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义运行时选择、profile、patch 与外部插件管理约定。 +除非调用方选择另一个 `dsh` 可执行程序或 profile,否则 SDK 会启动匹配的内置 `dsh --profile sdk` 运行时。可运行极简示例选择随附的独立 `sdk-minimal` profile;同一运行时还会为独立 CLI 使用打包 `dsh web` 及其前端产物。每次启动都要求显式选择 Harness home;Python 绝不会静默读取 `~/.dsh`。[SDK 参考](sdk/README.zh.md)和[运行时载体参考](sdk-runtime/README.zh.md)定义运行时选择、profile、patch 与外部插件管理约定。 ## 贡献者工作流 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index bff1468133..a372e966ac 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 145f958a9df28e76456fb16a68b36fe318afa323 -development.zh.md: 557d6ba1f766dca61ab7bc73bf71ced2eb79afbe +development.md: 61094a277d2b91063a0d368ec30f444eeb132128 +development.zh.md: a73de6e091050cebb0b26037a7cca3adc814d961 diff --git a/python/development.md b/python/development.md index 145f958a9d..61094a277d 100644 --- a/python/development.md +++ b/python/development.md @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the checked-in minimal composition's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job; it drops the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)). `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. +Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the shipped `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job. `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. Trusted pull requests also run `--scenario sdk-live --installed-wheel` on every native target. That scenario performs two tool-using turns against `https://api.deepseek.com`, verifies the created file externally, and fails when the repository secret is absent instead of self-skipping. Fork and Dependabot pull requests run the complete keyless installed-wheel path but receive no key. diff --git a/python/development.zh.md b/python/development.zh.md index 557d6ba1f7..a73de6e091 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定了签入的极简组合所组装的系统提示词、对外公布的工具 schema 以及模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败;它会丢弃动态运行时上下文快照——同一组合在 macOS 上会发出它,在 Linux 上不会([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 +其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定随附 `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 可信拉取请求还会在每个原生目标上运行 `--scenario sdk-live --installed-wheel`。该场景面向 `https://api.deepseek.com` 执行两个使用工具的轮次,从外部验证已创建文件,并在仓库密钥缺失时失败而不是自行 skip。Fork 与 Dependabot 拉取请求会运行完整的 keyless 安装后 wheel 路径,但不会获得密钥。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index ddcef99f9e..51965002ce 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: 52103b538be0ebc79572d8477e407f324e419ce6 -README.zh.md: e4fe7673a510102504454d5a6113ec7ec6ffd086 +README.md: 1552f2a120938ecab6d05dd244a745bec65bb00a +README.zh.md: 9524617ee1a950080476a91db5ec6e14727518ce diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 52103b538b..1552f2a120 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -12,7 +12,7 @@ Production executables are named `deepseek-harness-sdk-runtime-- Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. -Both carriers execute the same `dsh` grammar and profiles. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. +Both carriers execute the same `dsh` grammar and shipped profiles, including the standalone `sdk-minimal` tree and the full `web` profile with its frontend assets. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. ## Python module API diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index e4fe7673a5..9524617ee1 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -12,7 +12,7 @@ Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 与 sdist 均不包含它。 -两种载体执行相同的 `dsh` 语法和 profile。私有 `dsh-python-runtime-closure` manifest 定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 +两种载体执行相同的 `dsh` 语法与随附 profile,包括独立的 `sdk-minimal` 配置树,以及包含前端产物的完整 `web` profile。私有 `dsh-python-runtime-closure` manifest 定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 ## Python 模块 API diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 29b3a5a70b..1e98c9d0a3 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: ef2ba1764aa93fc6499941dd372a1de94f33d454 -README.zh.md: b5c36b6ff0fbce303649ae92efd7e1edac954e82 +README.md: 4138ff5b8fb476da2d82f710f72369d3855066a2 +README.zh.md: a51777a427deb65b461213aed28bb9df179ed1b1 diff --git a/python/sdk/README.md b/python/sdk/README.md index ef2ba1764a..4138ff5b8f 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -53,12 +53,14 @@ with DeepSeekHarness( `profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API. +The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"` and set the same model in `env={"DSH_MODEL": model}` so its one adapter route matches SDK initialization. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. + ## Results and notifications `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. `HarnessClient` retains discovered subagent ancestry for the runtime process lifetime. During `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and known descendants in wire order. `RunResult.events` contains root-session events only, so descendant output cannot replace the root response. The low-level `session_prompt()` returns the queued message id immediately; callers that bypass `Session.run()` own the later activity boundary. -The selected home stores profiles, plugins, credentials, settings, and sessions. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources. +The selected home stores profiles, plugins, and every profile-owned durable resource. The full `sdk` profile uses its credentials, settings, and session stores; `sdk-minimal` uses only its JSONL session store. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources. See the [Python tutorial](../../docs/user/guide/python-sdk.md), [`python-sdk-agent` example](../../examples/python-sdk-agent/README.md), and [runtime wheel reference](../sdk-runtime/README.md). diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index b5c36b6ff0..a51777a427 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -53,12 +53,14 @@ with DeepSeekHarness( `profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。 +随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它,并在 `env={"DSH_MODEL": model}` 中设置相同模型,使其唯一适配器路由与 SDK 初始化一致。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 + ## 结果与通知 `Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入 idle 时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的 assistant 文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 `HarnessClient` 会在运行时进程的整个生命周期内保留已发现的子 agent 祖先关系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。 -所选 home 保存 profile、插件、凭据、设置和会话。需要隔离这些资源时应使用新的 home;独立工作应使用新的 session id。同时复用 harness 与 session id 会延续持久对话和会话资源。 +所选 home 保存 profile、插件与每个 profile 自有的持久资源。完整 `sdk` profile 使用其中的凭据、设置与会话存储;`sdk-minimal` 只使用自己的 JSONL 会话存储。需要隔离这些资源时应使用新的 home;独立工作应使用新的 session id。同时复用 harness 与 session id 会延续持久对话和会话资源。 另见 [Python 教程](../../docs/user/guide/python-sdk.zh.md)、[`python-sdk-agent` 示例](../../examples/python-sdk-agent/README.zh.md)和[运行时 wheel 参考](../sdk-runtime/README.zh.md)。 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 79407ece40..c5452d8d4a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -763,7 +763,7 @@ const APP_EXAMPLES = [ title: 'DSH Base Composition', label: 'packages/bundle/base/cordis.patch.yml', config: 'packages/bundle/base/cordis.patch.yml', - summary: 'The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app, dsh-headless) and the user\'s profile layer patch over it.', + summary: 'The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; their mode bundles and user layers patch over it, while sdk-minimal owns a separate standalone tree.', }, { id: 'headless', From e2920f0109bb6c5f83f72569faa7c05c0a615d2d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:02:51 +0800 Subject: [PATCH 33/42] fix(sdk-minimal): make the SDK model argument authoritative Stop narrowing the standalone DeepSeek adapter to a DSH_MODEL-derived one-entry catalog. The direct adapter already accepts model ids outside its advisory catalog, so retain only the DSH_CONTEXT_WINDOW fallback and let the JSON-RPC initialize model be the single runtime selection. Remove model mirroring from minimal.py and the packaged smoke. The keyless process now initializes deepseek-v4-pro without DSH_MODEL, while the packaged scenario continues to use its unlisted smoke-model; together they prove both cataloged and arbitrary SDK model arguments reach the adapter directly. Update the bundle, tutorial, SDK/example references, and owning Agent Notes to keep DSH_MODEL only as minimal.py's optional default input, never as a second value callers must synchronize. --- .../2026-08-24-standalone-sdk-minimal-profile.i18n.yaml | 4 ++-- .../2026-08-24-standalone-sdk-minimal-profile.md | 2 +- .../2026-08-24-standalone-sdk-minimal-profile.zh.md | 2 +- ...26-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml | 4 ++-- .../2026-08-11-minimal-profiles-bare-two-tool-runtime.md | 4 ++-- .../2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md | 4 ++-- docs/user/guide/python-sdk.i18n.yaml | 4 ++-- docs/user/guide/python-sdk.md | 1 - docs/user/guide/python-sdk.zh.md | 1 - examples/python-sdk-agent/README.i18n.yaml | 4 ++-- examples/python-sdk-agent/README.md | 2 +- examples/python-sdk-agent/README.zh.md | 2 +- examples/python-sdk-agent/minimal.py | 1 - examples/python-sdk-agent/tests/keyless-smoke.e2e.ts | 1 - packages/bundle/sdk-minimal/README.i18n.yaml | 4 ++-- packages/bundle/sdk-minimal/README.md | 2 +- packages/bundle/sdk-minimal/README.zh.md | 2 +- packages/bundle/sdk-minimal/cordis.patch.yml | 4 +--- packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts | 5 +++++ python/sdk/README.i18n.yaml | 4 ++-- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- scripts/smoke-python-runtime.py | 3 --- 23 files changed, 30 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml index 6fb8fc0362..4a06faa161 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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/architecture/2026-08-24-standalone-sdk-minimal-profile.md -2026-08-24-standalone-sdk-minimal-profile.md: aa822d40a3b15bb55c36e8bcc585c4aaeb03c13d -2026-08-24-standalone-sdk-minimal-profile.zh.md: cea6ad6f575882b07d9494532e94cfe95bd61b43 +2026-08-24-standalone-sdk-minimal-profile.md: bc1a177dc4a232201004e6869caa452a7d55deb9 +2026-08-24-standalone-sdk-minimal-profile.zh.md: ae6fdb95079f748f26758a30c968c27548e9a87f diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md index aa822d40a3..bc1a177dc4 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -22,7 +22,7 @@ The bundle reuses `@deepseek-ai/dsh-sdk-app` for command help, stdin EOF, and bo ### Explicit composition -The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The persona comes from `DSH_SYSTEM_PROMPT`, the adapter route from `DSH_MODEL` and `DSH_CONTEXT_WINDOW`, and the credential from `DEEPSEEK_API_KEY`. +The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The SDK initialization request owns the model id; `DSH_CONTEXT_WINDOW` supplies fallback capacity for models outside the adapter's advisory catalog. The persona comes from `DSH_SYSTEM_PROMPT`, and the credential from `DEEPSEEK_API_KEY`. Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. This layer is POSIX-only because its persistent terminal uses Bash. diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md index cea6ad6f57..ae6fdb9507 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -22,7 +22,7 @@ Status: implemented ### 显式组合 -该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。Persona 来自 `DSH_SYSTEM_PROMPT`,适配器路由来自 `DSH_MODEL` 与 `DSH_CONTEXT_WINDOW`,凭据来自 `DEEPSEEK_API_KEY`。 +该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。SDK 初始化请求拥有模型 id;`DSH_CONTEXT_WINDOW` 为不在适配器建议目录中的模型提供后备容量。Persona 来自 `DSH_SYSTEM_PROMPT`,凭据来自 `DEEPSEEK_API_KEY`。 Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。由于持久终端使用 Bash,此层只支持 POSIX。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index 58474befe4..4a576e9339 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: dbbe0e253d23a7663bdd40ac302e779d3090e8e0 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 831b475f277beeef7e7542fb23c410311b45151b +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 6ea86832b632c631b7e02d6c486f3858fd2632a4 +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 73f6f16a51c14a7c98915a84878b39596d12f245 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index dbbe0e253d..6ea86832b6 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -16,7 +16,7 @@ Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_ The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. -`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) uses `DSH_MODEL` as its default `model` argument and passes an explicit `--model` back to the child environment so the catalog and request remain aligned. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. +`DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. ## Verification @@ -32,7 +32,7 @@ The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an **Use one Cordis leaf for Web and Python SDK startup.** Rejected because a Web preset contributes agent-scoped services to an existing multi-session host, while the Python SDK must launch a complete process containing the JSON-RPC server and its process-wide dependencies. -**Read `DSH_MODEL` only inside Cordis.** Rejected because Cordis configures the provider catalog but does not own the SDK client's JSON-RPC `initialize` request. The launcher must pass the same model to the client request for the environment value to select the routed model. +**Mirror the requested model into `DSH_MODEL`.** Rejected because the direct adapter accepts model ids outside its advisory catalog and resolves fallback context metadata for them. Mirroring creates two inputs for one selection; the SDK initialization request is authoritative, while `DSH_MODEL` remains only a convenience default in `minimal.py`. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index 831b475f27..73f6f16a51 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -16,7 +16,7 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` 独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 -`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)会使用 `DSH_MODEL` 作为 `model` 参数的默认值,并把显式 `--model` 传回子进程环境,使目录与请求保持一致。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 +`DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 ## 验证 @@ -32,7 +32,7 @@ SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环 **为 Web 与 Python SDK 启动使用同一个 Cordis leaf。** 不予采用,因为 Web preset 向现有多会话宿主贡献 agent 作用域服务,而 Python SDK 必须启动包含 JSON-RPC 服务器及其进程级依赖的完整进程。 -**只在 Cordis 内读取 `DSH_MODEL`。** 不予采用,因为 Cordis 配置提供方目录,但不拥有 SDK 客户端的 JSON-RPC `initialize` 请求。launcher 必须向客户端请求传递同一个模型,环境值才能选择路由模型。 +**把请求模型镜像到 `DSH_MODEL`。** 不予采用,因为直接适配器接受不在建议目录中的模型 id,并为它们解析后备上下文元数据。镜像会为同一项选择制造两个输入;SDK 初始化请求具有权威,`DSH_MODEL` 只保留为 `minimal.py` 的便捷默认值。 ## 后果 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 88d467f519..4ddf1a6015 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md -python-sdk.md: 2cf6acbd6718be560ec4365b356c031b17fb070e -python-sdk.zh.md: 2c53886ba3409e6fc21ec57fc79b81bf6907349c +python-sdk.md: 24f5594a20eab6870d9725e0f1acfce5dff62f74 +python-sdk.zh.md: 47b420ab20df04d28e5498807dc73a425c7a9666 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 2cf6acbd67..24f5594a20 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -61,7 +61,6 @@ with DeepSeekHarness( cwd=str(workspace), dsh_home=str(dsh_home), profile="sdk-minimal", - env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 2c53886ba3..47b420ab20 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -61,7 +61,6 @@ with DeepSeekHarness( cwd=str(workspace), dsh_home=str(dsh_home), profile="sdk-minimal", - env={"DSH_MODEL": "deepseek-v4-flash"}, ) as harness: result = harness.run( "Inspect the repository and fix the failing tests.", diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index 0c3a5fe203..e57c422380 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-agent/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 examples/python-sdk-agent/README.md -README.md: 5bb958c3e9202642926a0186d055615a895d9586 -README.zh.md: 97bfe9e1d19a7cd37ed41fccc64340dc054ecec7 +README.md: 46a8dc2384d96db39841c4c1e4cdc88d82ff55eb +README.zh.md: 5e6e2f89f27398dd7404894f15391a6a693d689b diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index 5bb958c3e9..46a8dc2384 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -17,7 +17,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` overrides the model and passes the same value to the profile-owned adapter catalog; `--profile` can select another SDK-serving profile. The selected home stores the generated `sdk-minimal` profile and uncompressed JSONL session logs under `sessions/`; the script never reads `~/.dsh` implicitly. +Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the script's default model, or `DSH_SYSTEM_PROMPT` for the deployment persona. `--model` is the single runtime model selection; no matching environment variable is required. `--profile` can select another SDK-serving profile. The selected home stores the generated `sdk-minimal` profile and uncompressed JSONL session logs under `sessions/`; the script never reads `~/.dsh` implicitly. The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../packages/bundle/sdk-minimal/README.md) is the complete explicit Cordis tree for this mode. It exposes exactly: diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index 97bfe9e1d1..5e6e2f89f2 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -17,7 +17,7 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` -兼容代理使用 `DEEPSEEK_BASE_URL`,默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 会覆盖模型,并将同一值传给 profile 自有的适配器目录;`--profile` 可以选择另一个提供 SDK 服务的 profile。所选 home 保存生成的 `sdk-minimal` profile,并在 `sessions/` 下保存未压缩 JSONL 会话日志;脚本绝不会隐式读取 `~/.dsh`。 +兼容代理使用 `DEEPSEEK_BASE_URL`,脚本默认模型使用 `DSH_MODEL`,deployment persona 使用 `DSH_SYSTEM_PROMPT`。`--model` 是唯一运行时模型选择,不要求匹配的环境变量;`--profile` 可以选择另一个提供 SDK 服务的 profile。所选 home 保存生成的 `sdk-minimal` profile,并在 `sessions/` 下保存未压缩 JSONL 会话日志;脚本绝不会隐式读取 `~/.dsh`。 随附的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../packages/bundle/sdk-minimal/README.zh.md)是该模式完整且显式的 Cordis 配置树。它只暴露: diff --git a/examples/python-sdk-agent/minimal.py b/examples/python-sdk-agent/minimal.py index 86c1dc8082..28d2fd8c54 100644 --- a/examples/python-sdk-agent/minimal.py +++ b/examples/python-sdk-agent/minimal.py @@ -39,7 +39,6 @@ def main() -> None: cwd=str(workspace), dsh_home=str(dsh_home), profile=args.profile, - env={"DSH_MODEL": args.model}, ) as harness: result = harness.run(args.prompt, session_id=args.session_id) print(result.final_response) diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index e0bd4ed6c3..04eff29319 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -209,7 +209,6 @@ describe('Python SDK dsh profile keyless smoke', () => { cwd: repoRoot, env: { DSH_HOME: join(root, '.dsh'), - DSH_MODEL: 'deepseek-v4-pro', DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index 715a58381e..c573869472 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/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/bundle/sdk-minimal/README.md -README.md: 7f37d0cb4996a9c48e4bcb934565ff2865f5e7e3 -README.zh.md: 3e2c5b5f66314e8dc2da9622bbd1937d85d98490 +README.md: b33ccb429ab0291d46f0271329b957f0ac7011fa +README.zh.md: 9e4ab381f5595630cbf139b1a1e8e30cef147f39 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index 7f37d0cb49..b33ccb429a 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -6,7 +6,7 @@ Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its s The profile remains part of the ordinary launcher and layering model. The bundle supplies the complete default tree; the profile patch, home patch, and ordered `--patch` files can replace rows or insert external bundles above it. `dsh plugin --profile sdk-minimal` manages persistent dependencies. The shipped template uses startup-only patches so one stdio connection never observes replacement of its server or agent dependencies. -`DEEPSEEK_API_KEY` supplies the adapter credential. `DSH_MODEL` selects the sole configured model, `DSH_CONTEXT_WINDOW` sets its context window, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. +`DEEPSEEK_API_KEY` supplies the adapter credential. The SDK initialization request is the sole model selection; the adapter accepts that model id even when it is absent from its advisory catalog. `DSH_CONTEXT_WINDOW` sets the fallback capacity for such models, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. ## Model Experience diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 3e2c5b5f66..9e4ab381f5 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -6,7 +6,7 @@ 该 profile 仍遵循普通 launcher 与分层模型。组合包提供完整默认树;profile patch、home patch 与有序 `--patch` 文件可以在其上替换配置项或插入外部组合包。`dsh plugin --profile sdk-minimal` 管理持久依赖。随附模板仅在启动时应用 patch,因此一个 stdio 连接不会观察到服务器或 agent 依赖在运行中被替换。 -`DEEPSEEK_API_KEY` 提供适配器凭据。`DSH_MODEL` 选择唯一配置的模型,`DSH_CONTEXT_WINDOW` 设置其上下文窗口,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 +`DEEPSEEK_API_KEY` 提供适配器凭据。SDK 初始化请求是唯一模型选择;即使该模型 id 不在适配器的建议目录中,适配器也会接受它。`DSH_CONTEXT_WINDOW` 为这类模型设置后备容量,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 ## 模型体验 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index e43f3e2b03..24d707c7e9 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -27,10 +27,8 @@ name: '@deepseek-ai/dsh-llm-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY + defaultContextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) streamIdleTimeoutMs: 172800000 - models: - - id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash' - contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index 4e9fbbcc6a..f8c23e92ac 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -44,6 +44,11 @@ describe('dsh-sdk-minimal bundle', () => { inject: ['sdkAppStartup', 'loader'], config: { maxTokensAsSuccess: false }, }) + expect(rows.find(row => row.id === 'llm-deepseek')?.config).toEqual({ + apiKeyEnv: 'DEEPSEEK_API_KEY', + defaultContextWindow: { __jsExpr: 'Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000)' }, + streamIdleTimeoutMs: 172800000, + }) expect(rows.find(row => row.id === 'agent-spine')?.config).toMatchObject({ includeHarnessIdentity: false, includeRuntimeContext: false, diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 1e98c9d0a3..5295ed3c11 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: 4138ff5b8fb476da2d82f710f72369d3855066a2 -README.zh.md: a51777a427deb65b461213aed28bb9df179ed1b1 +README.md: cf9bb3e3ccac4908e9212d8f7247545b5a6b5d8e +README.zh.md: 9acb8f26129144a77a834f94854cdd3f1a200086 diff --git a/python/sdk/README.md b/python/sdk/README.md index 4138ff5b8f..cf9bb3e3cc 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -53,7 +53,7 @@ with DeepSeekHarness( `profile` may select another existing profile, but that composition must retain `@deepseek-ai/dsh-sdk-app` or another `@deepseek-ai/dsh-sdk-jsonrpc-server` row. Misconfiguration fails during CLI boot or SDK initialization; there is no complete-config fallback. `dsh_bin` may select another `dsh` executable while preserving the same profile grammar. Arbitrary argv replacement remains an internal fake-runtime test adapter, not public API. -The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"` and set the same model in `env={"DSH_MODEL": model}` so its one adapter route matches SDK initialization. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. +The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides persistent Bash, the string-replace editor, local execution, and JSONL sessions; settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. ## Results and notifications diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index a51777a427..9acb8f2612 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -53,7 +53,7 @@ with DeepSeekHarness( `profile` 可以选择另一个已存在的 profile,但该组合必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 `@deepseek-ai/dsh-sdk-jsonrpc-server` 配置项。配置错误会在 CLI 启动或 SDK 初始化时失败;不存在完整配置回退。`dsh_bin` 可以选择另一个 `dsh` 可执行程序,同时保持相同的 profile 语法。任意 argv 替换仅是内部 fake-runtime 测试适配器,不属于公开 API。 -随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它,并在 `env={"DSH_MODEL": model}` 中设置相同模型,使其唯一适配器路由与 SDK 初始化一致。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 +随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 id。它提供持久 Bash、字符串替换 editor、本地执行与 JSONL 会话;settings、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 ## 结果与通知 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index ba5279000d..4a5a3eb0be 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -978,9 +978,6 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - dsh_bin=str(executable), dsh_home=str(dsh_home), profile="sdk-minimal", - env={ - "DSH_MODEL": "smoke-model", - }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, From 104fe9b9e786d695b6e27b4ad5063912d4fa4786 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:16:04 +0800 Subject: [PATCH 34/42] test(sdk-app): cover default profile configuration The startup test helper always constructed an explicit sdk profile, so coverage never exercised apply's supported default-config path even though runtime behavior depended on it.\n\nPass Config objects through the helper and default them to an empty config. The existing startup and help tests now prove sdk defaulting, while the explicit sdk-minimal case remains covered. --- packages/bundle/sdk-app/tests/startup.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bundle/sdk-app/tests/startup.spec.ts b/packages/bundle/sdk-app/tests/startup.spec.ts index 5f0ec6524a..ec128a1c4a 100644 --- a/packages/bundle/sdk-app/tests/startup.spec.ts +++ b/packages/bundle/sdk-app/tests/startup.spec.ts @@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' -import { apply, SDK_APP_STARTUP_SERVICE } from '../src/index.ts' +import { apply, type Config, SDK_APP_STARTUP_SERVICE } from '../src/index.ts' /** Controllable stdin for one startup invocation. */ class TestStdin extends EventEmitter { @@ -27,7 +27,7 @@ afterEach(() => { }) /** Run the provider with captured command output and exit requests. */ -function start(args: string[], profile = 'sdk'): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { +function start(args: string[], config: Config = {}): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { const ctx = new Context() const exits: number[] = [] const stdin = new TestStdin() @@ -41,7 +41,7 @@ function start(args: string[], profile = 'sdk'): { ctx: Context; exits: number[] exit: code => void exits.push(code), ready: { onReady: (listener) => { listener(); return () => {} } }, }) - apply(ctx, { profile }) + apply(ctx, config) return { ctx, exits, out: () => out, stdin } } @@ -64,7 +64,7 @@ describe('SDK app startup', () => { }) it('renders the selected SDK profile name in help', () => { - const { out } = start(['--help'], 'sdk-minimal') + const { out } = start(['--help'], { profile: 'sdk-minimal' }) expect(out()).toContain('Usage: dsh --profile sdk-minimal') expect(out()).toContain('dsh --profile sdk-minimal') }) From 4719bef9329b67da11d78a7ef94428dca8b09a66 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:27:52 +0800 Subject: [PATCH 35/42] test(python): exercise the shipped SDK profile directly The keyless max-token smoke carried a disable overlay that reproduced the removed private carrier's reduced tool roster. That legacy roster is now owned by the standalone sdk-minimal profile, so mutating the full sdk profile hides the application the SDK actually ships.\n\nLaunch sdk without a patch for both the successful and invalid-config paths. The separate sdk-minimal process test continues to pin its exact platform-selected two-tool request. --- .../tests/keyless-smoke.e2e.ts | 15 -------- .../python-sdk-agent/tests/keyless.patch.yml | 38 ------------------- 2 files changed, 53 deletions(-) delete mode 100644 examples/python-sdk-agent/tests/keyless.patch.yml diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 04eff29319..625fa2f16f 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -9,7 +9,6 @@ import { execa } from 'execa' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const patchPath = fileURLToPath(new URL('./keyless.patch.yml', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) const decompress = promisify(zstdDecompress) @@ -77,8 +76,6 @@ describe('Python SDK dsh profile keyless smoke', () => { binScript, '--profile', 'sdk', - '--patch', - patchPath, ], { cwd: repoRoot, env: { @@ -147,17 +144,7 @@ describe('Python SDK dsh profile keyless smoke', () => { }, }, }) - const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.max_tokens).toBe(1234) - expect(tools.map(tool => tool.function?.name).sort()).toEqual([ - 'bash', - 'edit', - 'read', - 'read_image', - 'subagent', - 'todo_write', - 'write', - ]) child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) @@ -283,8 +270,6 @@ describe('Python SDK dsh profile keyless smoke', () => { binScript, '--profile', 'sdk', - '--patch', - patchPath, ], { cwd: repoRoot, env: { diff --git a/examples/python-sdk-agent/tests/keyless.patch.yml b/examples/python-sdk-agent/tests/keyless.patch.yml deleted file mode 100644 index 71b5ec6ebd..0000000000 --- a/examples/python-sdk-agent/tests/keyless.patch.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Preserve the focused SDK test roster over the shipped sdk profile. - -- id: agent-instructions - disabled: true -- id: tool-jobs - disabled: true -- id: tool-fs-search - disabled: true -- id: skill-filesystem - disabled: true -- id: tool-skill - disabled: true -- id: tool-str-replace-editor - disabled: true -- id: tool-subagent-control - disabled: true -- id: tool-subagent-list-agents - disabled: true -- id: tool-subagent-fork - disabled: true -- id: tool-subagent-report - disabled: true -- id: tool-workflow - disabled: true -- id: tool-goal - disabled: true -- id: plan-mode - disabled: true -- id: tool-ralph - disabled: true -- id: tool-web - disabled: true - -- id: tool-subagent - config: - provider: spawn - toolName: subagent - backgroundMode: one-shot From f76a225a7db1560e1ed8b77d30fe4f2e7b774d65 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 24 Aug 2026 18:23:42 +0800 Subject: [PATCH 36/42] Merge pull request #2663 from deepseek-harness/feat/subagent-provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 subagent 按需发现并选择子 Agent 模型 --- ...8-10-fork-children-stay-one-shot.i18n.yaml | 4 +- .../2026-08-10-fork-children-stay-one-shot.md | 20 +- ...26-08-10-fork-children-stay-one-shot.zh.md | 20 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 6 +- .../2026-06-21-subagent-capability-seam.zh.md | 6 +- ...8-model-selected-subagent-routes.i18n.yaml | 6 + ...26-08-18-model-selected-subagent-routes.md | 61 ++ ...08-18-model-selected-subagent-routes.zh.md | 61 ++ apps/cli/tests/web-agent-presets.e2e.ts | 29 + .../models-settings/configured.expected.md | 4 + .../models-settings/declared-edit.expected.md | 4 + .../models-settings/declared.expected.md | 4 + .../models-settings/empty.expected.md | 4 + .../models.expected.md | 4 + .../dismissed.expected.md | 4 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 6 +- docs/capability-seams.zh.md | 6 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 10 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 34 +- docs/event-producer-consumer.zh.md | 16 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 336 +++++------ docs/module-graph.zh.md | 338 +++++------ docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 18 +- docs/persistence-catalog.zh.md | 18 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 8 +- docs/subsystems/core.zh.md | 8 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 27 +- docs/subsystems/subagent.zh.md | 27 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 40 +- docs/tool-catalog.zh.md | 40 +- examples/acp-agent/cordis.yml | 8 + .../acp-agent/depth-two.cordis.snapshot.yml | 1 + examples/acp-agent/depth-two.cordis.yml | 1 + ...gent-configured-effort.cordis.snapshot.yml | 65 +++ .../subagent-configured-effort.cordis.yml | 15 + examples/acp-agent/tests/acp.snapshot.ts | 25 +- .../advanced-toolchain/session.1.jsonl | 4 +- .../advanced-toolchain/session.2.jsonl | 4 +- .../system-prompt.expected.md | 16 +- .../tool-schemas.expected.json | 31 +- .../both-mode-turn/system-prompt.expected.md | 16 +- .../both-mode-turn/tool-schemas.expected.json | 31 +- .../system-prompt.expected.md | 16 +- .../code-mode-turn/system-prompt.expected.md | 16 +- .../tool-schemas.expected.json | 31 +- .../lsp-definition/tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../pty-tools/tool-schemas.expected.json | 31 +- .../tool-schemas.expected.json | 31 +- .../session.1.jsonl | 2 +- .../tool-schemas.expected.json | 31 +- .../input.json | 7 + .../replay.override.json | 32 + .../session.jsonl | 41 ++ .../stdout.expected.jsonl | 8 + .../session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../subagent-continuable/session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../session.1.jsonl | 4 +- .../session.2.jsonl | 4 +- .../subagent-fork-in-process/session.1.jsonl | 2 +- .../subagent-list-agents/session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../session.1.jsonl | 2 +- .../snapshots/subagent-mixed/session.1.jsonl | 4 +- .../snapshots/subagent-mixed/session.2.jsonl | 4 +- .../snapshots/subagent-multi/session.1.jsonl | 4 +- .../snapshots/subagent-multi/session.2.jsonl | 4 +- .../subagent-parallel/session.1.jsonl | 10 +- .../subagent-parallel/session.2.jsonl | 10 +- .../snapshots/subagent-report/session.1.jsonl | 2 +- .../tool-schemas.1.expected.json | 31 +- .../subagent-spawn-in-process/session.1.jsonl | 2 +- .../text-turn/tool-schemas.expected.json | 31 +- .../web-fetch/tool-schemas.expected.json | 31 +- .../snapshots/workflow-run/session.1.jsonl | 2 +- examples/headless-agent/cordis.yml | 12 +- .../advanced-toolchain/session.1.jsonl | 12 +- .../advanced-toolchain/session.2.jsonl | 12 +- .../advanced-toolchain/session.jsonl | 28 +- .../compaction-recovery/session.jsonl | 14 +- .../tests/snapshots/pty-tools/session.jsonl | 34 +- .../subagent-settlement/child.expected.jsonl | 2 +- .../parent-override/child.expected.jsonl | 4 +- .../parent-override/parent.expected.jsonl | 9 +- .../tests/subagent-inheritance.snapshot.ts | 21 +- examples/python-sdk-agent/cordis.yml | 1 + .../tests/keyless-smoke.e2e.ts | 2 + .../notifications.expected.jsonl | 2 +- .../subagent-spawn-in-process/session.1.jsonl | 2 +- .../tests/session-cold.host.spec.ts | 7 +- packages/bundle/base/cordis.patch.yml | 11 +- packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../src/client/ModelsSection.module.css | 84 ++- .../src/client/ModelsSection.tsx | 13 + .../src/client/SubagentModelSelectionCard.tsx | 87 +++ .../ui-settings-models/src/client/locales.ts | 8 + .../ui-settings-models/src/client/store.ts | 9 + .../tests/components.client.spec.tsx | 81 ++- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 3 +- packages/core/agent-loop/README.zh.md | 3 +- packages/core/agent-loop/src/agent.ts | 3 +- packages/core/agent-loop/src/index.ts | 3 +- packages/core/agent-loop/tests/loop.spec.ts | 41 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/runtime-types.ts | 4 +- .../core/session/src/known-event-types.ts | 1 + .../core/tools/tests/gen-tool-catalog.spec.ts | 4 +- .../preview-architecture-review/session.jsonl | 2 +- .../preview-follow-up-builder/session.jsonl | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 21 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 4 +- packages/llm/llm-deepseek/README.zh.md | 4 +- packages/llm/llm-deepseek/src/adapter.ts | 30 +- packages/llm/llm-deepseek/src/index.ts | 14 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 66 ++- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/package.json | 1 + .../presets/code/agent.cordis.yml | 5 + .../presets/cordis/agent.cordis.yml | 5 + .../presets/standard/agent.cordis.yml | 5 + packages/preset/agent-presets/src/index.ts | 14 +- .../preset/agent-presets/tests/mount.spec.ts | 21 + packages/preset/agent-presets/tsconfig.json | 3 + .../server/tests/built-scope-carrier.e2e.ts | 2 +- packages/sdk/server/tests/server.spec.ts | 10 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 6 +- packages/subagent/subagent-acp/README.zh.md | 6 +- packages/subagent/subagent-acp/src/index.ts | 12 +- .../subagent-acp/tests/subagent-acp.spec.ts | 8 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 4 +- .../subagent-claude-code/README.zh.md | 4 +- .../tests/loader-composition.e2e.ts | 4 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 4 +- packages/subagent/subagent-codex/README.zh.md | 4 +- .../tests/loader-composition.e2e.ts | 3 + .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 6 +- .../subagent/subagent-dsh-sdk/README.zh.md | 6 +- .../subagent/subagent-dsh-sdk/src/index.ts | 2 +- .../tests/subagent-dsh-sdk.spec.ts | 1 + .../subagent-fork-in-process/README.i18n.yaml | 4 +- .../subagent-fork-in-process/README.md | 7 +- .../subagent-fork-in-process/README.zh.md | 7 +- .../subagent-fork-in-process/src/index.ts | 23 +- .../tests/subagent-fork-in-process.spec.ts | 10 +- .../README.i18n.yaml | 4 +- .../subagent-in-process-driver/README.md | 2 +- .../subagent-in-process-driver/README.zh.md | 2 +- .../tests/structured.spec.ts | 2 +- .../README.i18n.yaml | 4 +- .../subagent-spawn-in-process/README.md | 8 +- .../subagent-spawn-in-process/README.zh.md | 8 +- .../subagent-spawn-in-process/src/index.ts | 14 +- .../tests/subagent-spawn-in-process.spec.ts | 10 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 7 +- packages/subagent/subagent/README.zh.md | 7 +- packages/subagent/subagent/src/child-agent.ts | 49 +- .../subagent/subagent/src/continuation.ts | 14 +- packages/subagent/subagent/src/descriptor.ts | 11 +- packages/subagent/subagent/src/index.ts | 2 + .../subagent/subagent/src/out-of-process.ts | 3 +- packages/subagent/subagent/src/types.ts | 7 + .../subagent/tests/child-agent.spec.ts | 76 +++ .../subagent/tests/continuation.spec.ts | 66 ++- .../subagent/subagent/tests/invariant.spec.ts | 2 +- .../subagent/tests/out-of-process.spec.ts | 8 +- .../subagent/subagent/tests/service.spec.ts | 16 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 33 +- packages/subagent/tool-subagent/README.zh.md | 33 +- packages/subagent/tool-subagent/package.json | 10 + packages/subagent/tool-subagent/src/index.ts | 545 ++++++++++++------ .../subagent/tool-subagent/src/invariant.ts | 26 +- .../subagent/tool-subagent/src/list-models.ts | 94 +++ .../src/model-selection-settings.ts | 70 +++ .../src/model-selection-state.ts | 33 ++ .../tool-subagent/src/model-selection.ts | 112 ++++ .../subagent/tool-subagent/tests/harness.ts | 57 ++ .../tool-subagent/tests/list-models.spec.ts | 191 ++++++ .../tests/model-selection-settings.spec.ts | 248 ++++++++ .../tests/model-selection.spec.ts | 365 ++++++++++++ .../tool-subagent/tests/scripted-provider.ts | 1 + .../tool-subagent/tests/tool-subagent.spec.ts | 169 +++--- packages/subagent/tool-subagent/tsconfig.json | 9 + .../subagent/tool-subagent/tsdown.config.ts | 19 + .../tool-ralph/tests/tool-ralph.spec.ts | 1 + .../tool-workflow/tests/tool-workflow.spec.ts | 2 +- .../tests/built-worker.e2e.ts | 2 +- .../tests/source-worker.compat.spec.ts | 2 +- .../tests/workflow-worker-thread.spec.ts | 20 +- pnpm-lock.yaml | 9 + scripts/check-workspace-constraints.ts | 3 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 8 + scripts/gen-tool-catalog.ts | 15 +- .../advanced/result.json | 14 +- .../advanced/session.1.jsonl | 4 +- .../advanced/session.2.jsonl | 4 +- tsconfig.base.json | 1 + 227 files changed, 4353 insertions(+), 1029 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md create mode 100644 examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-configured-effort.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl create mode 100644 packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx create mode 100644 packages/subagent/subagent/tests/child-agent.spec.ts create mode 100644 packages/subagent/tool-subagent/src/list-models.ts create mode 100644 packages/subagent/tool-subagent/src/model-selection-settings.ts create mode 100644 packages/subagent/tool-subagent/src/model-selection-state.ts create mode 100644 packages/subagent/tool-subagent/src/model-selection.ts create mode 100644 packages/subagent/tool-subagent/tests/harness.ts create mode 100644 packages/subagent/tool-subagent/tests/list-models.spec.ts create mode 100644 packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts create mode 100644 packages/subagent/tool-subagent/tests/model-selection.spec.ts create mode 100644 packages/subagent/tool-subagent/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml index 7c2052c0a3..7342dd6abc 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.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/architecture/2026-08-10-fork-children-stay-one-shot.md -2026-08-10-fork-children-stay-one-shot.md: 44b947a3e0580263f1973aaf24534b7b2f01c0b6 -2026-08-10-fork-children-stay-one-shot.zh.md: acb12c54fa37d4462cac1b1035bc74d5a96ea719 +2026-08-10-fork-children-stay-one-shot.md: b2d9a77d7cc9969e517a4f5d6973aa2aee1134f5 +2026-08-10-fork-children-stay-one-shot.zh.md: b5dc1a7fda4e2b4152baf63e9c49b89bcebdeef0 diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md index 44b947a3e0..b2d9a77d7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md @@ -1,4 +1,4 @@ -# Agent Note: Forked children stay one-shot +# Agent Note: Cache-preserving forked children stay one-shot Status: implemented @@ -12,7 +12,7 @@ The child-scoped `report` return channel is now the largest such addition, and s ## Decision -Every shipped composition binds the fork delegation tool to `backgroundMode: one-shot`: [the base bundle](../../../../packages/bundle/base/cordis.patch.yml), [the ACP example](../../../../examples/acp-agent/cordis.yml), and [the headless example](../../../../examples/headless-agent/cordis.yml). The base bundle leaves `run_in_background` available, because it mounts a task service; the two examples set `enableRunInBackground: false`, because they mount none and a one-shot background start would otherwise fail at call time on a missing `tasks` service. +The cache-preserving compositions bind the fork delegation tool to `backgroundMode: one-shot`: [the base bundle](../../../../packages/bundle/base/cordis.patch.yml), [the ACP example](../../../../examples/acp-agent/cordis.yml), and [the headless example](../../../../examples/headless-agent/cordis.yml). The base bundle leaves `run_in_background` available, because it mounts a task service; the two examples set `enableRunInBackground: false`, because they mount none and a one-shot background start would otherwise fail at call time on a missing `tasks` service. The standard, code, and Cordis CLI presets instead bind fork to `continuable`; their child-scoped `report` additions invalidate the inherited prefix and accept the recomputation cost described here. One-shot children — foreground and background alike — are created through `SubagentRuntime.start()`, which never enters the continuable activation-setup registry, so neither `report` nor its prompt section is installed. A forked one-shot child's system prompt and tool schemas therefore equal its parent's, apart from the `persona` and `toolFilter` deltas a deployment opts into per delegation tool. @@ -20,9 +20,9 @@ One-shot children — foreground and background alike — are created through `S ### The restriction is composition, not code -`ForkInProcessProvider.prepareContinuable` stays implemented and `ctx.subagents.startContinuable()` still accepts `fork`; only the shipped `cordis.yml` rows changed. `tool-subagent` knows both the provider's `inheritsParentContext` and its own `backgroundMode` at mount, so a load-time rejection of the pair was available and is deliberately not added: the pair is not wrong in general. It is wrong only while a child-scope delta precedes inherited history, and the package that creates that delta — [`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.md) — is separately installable and, by its own design, invisible to `tool-subagent`. A deployment that omits the report package can run continuable forked children with the prefix intact. Encoding one roster's consequence as a delegation-tool invariant would make the tool assert something it cannot observe. +`ForkInProcessProvider.prepareContinuable` stays implemented and `ctx.subagents.startContinuable()` accepts `fork`; composition chooses whether the fork tool is one-shot or continuable. `tool-subagent` knows both the provider's `inheritsParentContext` and its own `backgroundMode` at mount, so a load-time rejection of the pair is available and deliberately absent: the pair is not wrong in general. It is costly only while a child-scope delta precedes inherited history, and the package that creates that delta — [`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.md) — is separately installable and, by its own design, invisible to `tool-subagent`. A deployment that omits the report package can run continuable forked children with the prefix intact. Encoding one roster's consequence as a delegation-tool invariant would make the tool assert something it cannot observe. -The reintroduction condition is recorded as a `TODO(fork-continuable-prefix-reuse)` marker on `prepareContinuable` itself, the one method the shipped compositions do not call, and tracked as issue #2124: continuable fork reopens when a child's system prompt and tool schemas can match its parent's byte for byte. +The cache-preserving condition is recorded as a `TODO(fork-continuable-prefix-reuse)` marker on `prepareContinuable` and tracked as issue #2124: continuable fork preserves its inherited prefix when the child's system prompt and tool schemas can match the parent's byte for byte. ## Alternatives considered @@ -30,7 +30,7 @@ The reintroduction condition is recorded as a `TODO(fork-continuable-prefix-reus **Stop mounting the fork provider at all.** This was the broader form of the restriction. Rejected because foreground fork *is* the prefix-reusing case and is untouched by the report channel, so a full ban gives up the capability without buying anything the one-shot binding does not already buy — and would leave no shipped composition exercising session seeding. -**Ship continuable forked children and accept the loss.** Rejected because the loss is total rather than marginal: reuse breaks ahead of the inherited history, so the child pays full prefill on a transcript it duplicated for the sole purpose of not paying it. A deployment that wants a long-lived child with no inherited context already has `spawn`. +**Use continuable forked children in cache-preserving compositions and accept the loss.** Rejected for the base bundle and ACP/headless examples because the loss is total rather than marginal: reuse breaks ahead of the inherited history, so the child pays full prefill on a transcript it duplicated for the sole purpose of not paying it. The CLI presets make the other tradeoff and retain continuable fork. A deployment that wants a long-lived child with no inherited context already has `spawn`. **Make `report` visible to every Agent.** A global registration would restore byte-identical prefixes by giving parent and child the same schema and section. Rejected because roots, one-shot children, remote children, and agentless callers would advertise a tool with no derivable recipient, and execution-time rejection would make schema visibility disagree with authority — the scope-local decision the [report tool Agent Note](../feature/2026-07-30-continuable-subagent-report-tool.md) already settled. @@ -38,12 +38,12 @@ The reintroduction condition is recorded as a `TODO(fork-continuable-prefix-reus ## Consequences -- No shipped composition creates a continuable forked child; `subagent_fork` returns a result to its caller's turn, and `send_message` addresses only spawned children. -- A forked child's request prefix stays byte-identical to its parent's unless the deployment configures `persona` or `toolFilter` on the fork delegation tool, so the token cost of seeding buys provider-side reuse again. -- The fork provider's continuable path has no production caller and no assembled-composition coverage. It keeps its package-level tests, and the seam still accepts it, so a bundle or `--patch` overlay can reintroduce it with no code change and no warning. +- The base bundle and ACP/headless examples create only one-shot forked children; their `subagent_fork` returns a result to the caller's turn, and `send_message` addresses only spawned children there. The three CLI presets create continuable forked children. +- A one-shot forked child's request prefix stays byte-identical to its parent's unless the deployment configures `persona`, `toolFilter`, or a different LLM route on the fork delegation tool, so the token cost of seeding can buy provider-side reuse. Continuable fork adds `report` before the inherited history and forfeits that reuse. +- The fork provider's continuable path has CLI production callers and package-level tests. The same seam accepts one-shot composition, so a bundle or `--patch` overlay can choose either lifecycle without a code change or warning. - `subagent_fork`'s model-visible schema changes: the continuable background wording is replaced by the one-shot task wording in the base bundle, and disappears entirely from the two examples. The affected keyless snapshot tool-schema sidecars are re-recorded in the same change. -- The report obligation's reach narrows to spawned children in shipped deployments. Its default `next-step` scheduling, authority model, and coverage remain independent of fork composition. +- The report obligation reaches spawned children in every continuable composition and forked children in the CLI presets. Its default `next-step` scheduling, authority model, and coverage remain independent of fork composition. ### Accepted risks -The constraint lives in three configuration files and a code comment, not in a gate. A future bundle row or profile patch can set `backgroundMode: continuable` on a fork tool and silently reintroduce the prefix loss; nothing fails loud. That is the accepted cost of not encoding one roster's consequence into `tool-subagent`. +The one-shot constraint lives in three configuration files and a code comment, not in a gate; the CLI preset rows already choose `backgroundMode: continuable` and incur the prefix loss. Any bundle or profile patch can make either choice without a warning. That is the accepted cost of not encoding one roster's consequence into `tool-subagent`. diff --git a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md index acb12c54fa..b5dc1a7fda 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md @@ -1,4 +1,4 @@ -# Agent Note: fork 出的 child 保持 one-shot +# Agent Note: 保留缓存的 fork child 保持 one-shot Status: implemented @@ -12,7 +12,7 @@ fork 与 spawn 的唯一区别是 child 的 Session 会以 parent 已完成轮 ## 决策 -所有随附组合都把 fork 委派工具绑定为 `backgroundMode: one-shot`:[base 组合包](../../../../packages/bundle/base/cordis.patch.yml)、[ACP 示例](../../../../examples/acp-agent/cordis.yml)与[headless 示例](../../../../examples/headless-agent/cordis.yml)。base 组合包保留 `run_in_background`,因为它挂载了 task 服务;两个示例设置 `enableRunInBackground: false`,因为它们都不挂载 task 服务,否则一次 one-shot 后台启动会在调用时因缺少 `tasks` 服务而失败。 +保留缓存的组合会把 fork 委派工具绑定为 `backgroundMode: one-shot`:[base 组合包](../../../../packages/bundle/base/cordis.patch.yml)、[ACP 示例](../../../../examples/acp-agent/cordis.yml)与[headless 示例](../../../../examples/headless-agent/cordis.yml)。base 组合包保留 `run_in_background`,因为它挂载了 task 服务;两个示例设置 `enableRunInBackground: false`,因为它们都不挂载 task 服务,否则一次 one-shot 后台启动会在调用时因缺少 `tasks` 服务而失败。standard、code 与 Cordis CLI preset 则把 fork 绑定为 `continuable`;其子级作用域的 `report` 增量会使继承前缀失效,并接受这里说明的重算成本。 one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` 创建,该路径从不进入可继续的 activation setup 注册表,因此 `report` 与它的提示词 section 都不会被安装。于是一个 fork 出的 one-shot child 的系统提示词与工具 schema 与其 parent 相同,只差部署逐个委派工具主动选择的 `persona` 与 `toolFilter` 增量。 @@ -20,9 +20,9 @@ one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` ### 该限制在于组合,不在于代码 -`ForkInProcessProvider.prepareContinuable` 仍然实现完好,`ctx.subagents.startContinuable()` 也仍接受 `fork`;改动的只有随附的 `cordis.yml` 行。`tool-subagent` 在挂载时同时知道提供方的 `inheritsParentContext` 与自身的 `backgroundMode`,因此一个加载期拒绝该组合的检查是可行的,而这里刻意不加:该组合并非普遍错误。它只在某个 child 作用域增量位于继承历史之前时才是错的,而产生该增量的包——[`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.zh.md)——是独立安装的,并且按其自身设计对 `tool-subagent` 不可见。一个不安装 report 包的部署可以在前缀完好的前提下运行可继续的 fork child。把某一份插件清单的后果写成委派工具的不变量,会让该工具断言它无法观察到的事实。 +`ForkInProcessProvider.prepareContinuable` 仍然实现完好,`ctx.subagents.startContinuable()` 也接受 `fork`;组合会选择 fork 工具采用 one-shot 还是 continuable。`tool-subagent` 在挂载时同时知道提供方的 `inheritsParentContext` 与自身的 `backgroundMode`,因此一个加载期拒绝该组合的检查是可行的,而这里刻意不加:该组合并非普遍错误。只有在某个 child 作用域增量位于继承历史之前时,它才会产生高昂成本,而产生该增量的包——[`dsh-tool-subagent-report`](../../../../packages/subagent/tool-subagent-report/README.zh.md)——是独立安装的,并且按其自身设计对 `tool-subagent` 不可见。一个不安装 report 包的部署可以在前缀完好的前提下运行可继续的 fork child。把某一份插件清单的后果写成委派工具的不变量,会让该工具断言它无法观察到的事实。 -重新开放的条件记录为 `prepareContinuable` 方法上的 `TODO(fork-continuable-prefix-reuse)` 标记——随附组合不调用这个方法——并由 issue #2124 跟踪:当 child 的系统提示词与工具 schema 能与其 parent 逐字节一致时,可继续 fork 即可重新开放。 +保留缓存的条件记录为 `prepareContinuable` 方法上的 `TODO(fork-continuable-prefix-reuse)` 标记,并由 issue #2124 跟踪:当 child 的系统提示词与工具 schema 能与其 parent 逐字节一致时,可继续 fork 就能保留继承前缀。 ## 备选方案 @@ -30,7 +30,7 @@ one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` **干脆不挂载 fork 提供方。** 这是该限制更彻底的形式。否决的原因是前台 fork *正是*复用前缀的那种情形,且不受 report 通道影响,因此全面禁用会在不换来任何 one-shot 绑定尚未换来的东西的同时放弃该能力——并且随附组合将没有任何一个演练 session 初始内容。 -**照常随附可继续的 fork child 并接受这份损失。** 否决的原因是这份损失是全额而非边际的:复用在继承历史之前就已中断,于是 child 为一份自己复制过来、目的恰恰是不必付费的 transcript 付了全额预填充。想要一个没有继承上下文的长期 child 的部署,本来就有 `spawn`。 +**在保留缓存的组合中随附可继续的 fork child 并接受这份损失。** base 组合包与 ACP/headless 示例不采用,因为这份损失是全额而非边际的:复用在继承历史之前就已中断,于是 child 为一份自己复制过来、目的恰恰是不必付费的 transcript 付了全额预填充。CLI preset 选择了另一项取舍并保留可继续 fork。想要一个没有继承上下文的长期 child 的部署,本来就有 `spawn`。 **让 `report` 对每个 Agent 可见。** 全局注册会通过让 parent 与 child 拥有相同的 schema 与 section 来恢复逐字节相同的前缀。否决的原因是根 agent、one-shot child、远端 child 与无 agent 调用方都会宣告一件推导不出收件方的工具,而执行期拒绝会让 schema 可见性与权限彼此矛盾——这正是[report 工具 Agent Note](../feature/2026-07-30-continuable-subagent-report-tool.zh.md)已经定下的作用域局部决策。 @@ -38,12 +38,12 @@ one-shot child——前台与后台皆然——经由 `SubagentRuntime.start()` ## 后果 -- 没有任何随附组合会创建可继续的 fork child;`subagent_fork` 把结果返回给调用方的轮次,而 `send_message` 只寻址 spawn 出的 child。 -- 除非部署在 fork 委派工具上配置了 `persona` 或 `toolFilter`,fork child 的请求前缀与其 parent 逐字节相同,因此初始内容的 token 成本重新换来了提供方侧的复用。 -- fork 提供方的可继续路径没有生产调用方,也没有整体组装层面的覆盖。它保留自己的包内测试,seam 也仍然接受它,因此某个组合包或 `--patch` 覆盖层可以无需改动代码、也不会有任何警告地把它重新引入。 +- base 组合包与 ACP/headless 示例只创建 one-shot fork child;其中的 `subagent_fork` 会把结果返回给调用方的轮次,`send_message` 也只寻址 spawn 出的 child。三个 CLI preset 会创建可继续的 fork child。 +- 除非部署在 fork 委派工具上配置了 `persona`、`toolFilter` 或不同的 LLM 路由,one-shot fork child 的请求前缀会与其 parent 逐字节相同,因此初始内容的 token 成本可以换来提供方侧的复用。可继续 fork 会在继承历史之前增加 `report`,从而失去该复用。 +- fork 提供方的可继续路径有 CLI 生产调用方与包内测试。同一条 seam 也接受 one-shot 组合,因此某个组合包或 `--patch` 覆盖层可以无需改动代码、也不会有任何警告地选择任一生命周期。 - `subagent_fork` 面向模型的 schema 发生变化:base 组合包中可继续的后台措辞被 one-shot 的 task 措辞取代,在两个示例中则完全消失。受影响的无密钥快照工具 schema 伴随文件在同一次改动中重新记录。 -- 在随附部署中,report 义务的覆盖范围收窄到 spawn 出的 child。它的 `next-step` 默认调度、权限模型与覆盖仍独立于 fork 组合。 +- 在每个可继续组合中,report 义务都会覆盖 spawn 出的 child;在 CLI preset 中,它也覆盖 fork 出的 child。它的 `next-step` 默认调度、权限模型与覆盖仍独立于 fork 组合。 ### 已接受的风险 -该限制存在于三个配置文件与一处代码注释中,而不在门禁里。未来某个组合包行或 profile 补丁可以在 fork 工具上设置 `backgroundMode: continuable`,从而悄然重新引入前缀损失;没有任何东西会失败得很响亮。这就是不把某一份插件清单的后果写入 `tool-subagent` 所接受的代价。 +one-shot 限制存在于三个配置文件与一处代码注释中,而不在门禁里;CLI preset 行已经选择 `backgroundMode: continuable` 并承担前缀损失。任何组合包或 profile 补丁都能选择任一方式,且不会收到警告。这就是不把某一份插件清单的后果写入 `tool-subagent` 所接受的代价。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index 780c4af5ea..57a5f4244b 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.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-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: bc84d88d701a5f3018bf00f0ecf8b60750917407 -2026-06-21-subagent-capability-seam.zh.md: 7932181d5667fc8a69ca7aa450fcbf6270ef14d5 +2026-06-21-subagent-capability-seam.md: 70ae99725dffee48292c3481df049563fb54a82c +2026-06-21-subagent-capability-seam.zh.md: 2f63c9022fb116daa2bb6ccd9150d96a823657de diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index bc84d88d70..70ae99725d 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -45,7 +45,7 @@ A provider exposes `start(request) → Promise`. Fulfillment publis ### Two kinds of optional capability, discovered two ways -- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. +- **Start-time features** (`agentOptions`, `outputSchema`, `depthLimit`, `toolFilter`, `persona`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. - **Continuable creation** is the optional `SubagentProvider.prepareContinuable` method; presence is the capability and TypeScript narrowing is the discovery mechanism, so no separate flag can drift from the implementation. The continuation manager owns later delivery and cold resume directly through `AgentHandle`, while one-shot `SubagentRun` has no steering or resume operation, as refined by [continuable subagents](2026-07-28-continuable-subagent-conversations.md). ### Fork vs. fresh are separate backends, not a flag @@ -60,9 +60,9 @@ Each in-process subagent runs in its **own `Session`** (own id, `parentSession` `dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output; they present the optional safe diagnostic owned by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) separately from partial assistant text. Independent result and disposal rejections remain independently observable. -### Provider selection is config, not model-facing +### Transport provider selection is config, not model-facing -`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — the schema carries no provider/type parameter. +`dsh-tool-subagent` binds to exactly one subagent transport provider name (`Config.provider`). To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — its schema carries no subagent transport/type parameter. A later opt-in adds child LLM provider/model fields without changing this transport decision; see [model-selected subagent routes](2026-08-18-model-selected-subagent-routes.md). ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 7932181d56..2f63c9022f 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -45,7 +45,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.zh.md)) ### 两类可选能力,两种发现方式 -- **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**响亮拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 +- **启动时功能**(`agentOptions`、`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**响亮拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。 - **可继续创建**使用可选的 `SubagentProvider.prepareContinuable` 方法;方法是否存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flag。继续执行管理器直接通过 `AgentHandle` 负责后续投递与冷恢复,而一次性 `SubagentRun` 没有 steering 或 resume 操作,具体由[可继续 subagent](2026-07-28-continuable-subagent-conversations.zh.md) 细化。 ### Fork 与 fresh 是独立后端,而非一个 flag @@ -60,9 +60,9 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.zh.md)) `dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;它会把由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的可选安全诊断与部分 assistant 文本分开呈现。结果与 dispose 的拒绝仍可彼此独立地观察。 -### 提供方选择是配置,不面向模型 +### 传输提供方选择是配置,不面向模型 -`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——schema 中没有提供方/type 参数。 +`dsh-tool-subagent` 绑定到恰好一个 subagent 传输提供方名称(`Config.provider`)。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——schema 中没有 subagent 传输/type 参数。后续 opt-in 增加了子 agent LLM 提供方/模型字段,但没有改变这项传输决策;见[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml new file mode 100644 index 0000000000..d21dd5be39 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-18-model-selected-subagent-routes.md +2026-08-18-model-selected-subagent-routes.md: 1602e3ac90870edbd0206cd87fdf97ecc34cad41 +2026-08-18-model-selected-subagent-routes.zh.md: 0dad8b9d030e6de65cb3fa1e0e93ad7c28bcc5c1 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md new file mode 100644 index 0000000000..1602e3ac90 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -0,0 +1,61 @@ +# Agent Note: Model-selected subagent routes + +Status: implemented + +English | [中文](2026-08-18-model-selected-subagent-routes.zh.md) + +## Problem + +`dsh-tool-subagent` can configure child `AgentOptions`, and both in-process providers merge those values over the parent Agent's LLM selection. The model-facing tool could not request a different provider, model, or reasoning effort for one suitable subtask. Loading one distinctly named delegation tool per LLM route duplicates schemas and turns a per-call scheduling choice into deployment configuration. + +The model also needs a bounded way to discover live providers and model-owned effort ids. Rendering the adapter directory into every delegation description would make an advisory, mutable catalog part of the prompt prefix. + +## Decision + +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. + +Provider and model form one route and must be supplied together. An effort may be supplied alone when configured or parent values provide the effective route. Model arguments override `Config.agentOptions`, and configured fields override the parent Agent's latest logged request selection; creation options supply the fallback before its first request and retain the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort, while changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. + +An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` before child creation. That lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. The tool checks cancellation again after the asynchronous lookup and before creating a child or background job. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. + +An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. + +Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. + +The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix. + +`SubagentCapabilities.agentOptions` remains the transport truth. The service rejects a request carrying those options before calling a provider that advertises `false`. Both in-process providers advertise `true`; the current ACP, Codex, Claude Code, and DSH SDK transports advertise `false`. Tool configuration that supplies `agentOptions`, statically enables model selection, or makes it settings-controlled also fails when its bound provider lacks the capability. + +## Alternatives considered + +**Keep a deployment-configured route allowlist.** Rejected because it duplicates the live LLM registry, requires configuration before the model can use an already registered route, and creates a second policy surface for clients to edit. Deployments that must restrict LLM access should control which provider routes they register. + +**Render the live adapter catalog in every delegation description.** Rejected because one provider can advertise hundreds of models, inflating every request, and catalog changes would rewrite an early cache-prefix definition. The on-demand directory keeps mutable data out of the fixed schema. + +**Use the advertised catalog as an allowlist.** Rejected because adapter catalogs are advisory and some providers accept arbitrary exact model ids. Exact resolution remains authoritative. + +**Add discovery methods to the subagent service.** Rejected because provider/model/effort metadata already belongs to `ctx.llm`; the new tool is a model-facing consumer of that existing capability. + +**Export discovery as a separately loaded plugin entry.** Rejected because shipped compositions always pair discovery with their primary delegation tool. Explicit ownership on that instance prevents duplicate global names without another Cordis config entry or lifecycle. + +**Configure discovery independently from model-facing selection.** Rejected because discovery exists to supply valid route and effort identifiers to the same model that can select them. One switch prevents a tool schema from advertising selection without its discovery path, or discovery without an applicable delegation route. + +**Enable model-facing route selection on shipped fork tools.** Not shipped because changing provider or model forfeits the inherited prefix's KV Cache reuse and can make prefix recomputation more expensive than the delegated work. The option can be reconsidered when reuse survives the route change or the interface makes that cost explicit and bounded. + +**Use a global reasoning-effort enum.** Rejected because effort identifiers and defaults belong to an exact provider/model route. The LLM adapter validates them without central translation or clamping. + +**Allow remote providers to ignore the fields.** Rejected because the request would claim a route choice that did not happen. The capability flag makes the unsupported path fail before child creation. + +## Consequences + +- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. +- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation. +- Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. +- Omission retains configured defaults and compatible inheritance from the parent's latest logged request; a route change without an explicit effort uses the selected model's default. +- Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. +- Out-of-process subagent providers reject configured and model-selected Agent options until they implement and advertise the capability. +- Unit coverage owns the default-off Host preference, new-Session sampling, child inheritance, resumed decisions, opt-in schema and execution enforcement, merge precedence, route-aware effort inheritance, preflight cancellation, live discovery, diagnostics, definition stability, capability rejection, and optional-service behavior. A shipped headless snapshot pins inheritance from a logged parent selection; the shipped examples also own the assembled keyless model-visible schemas. + +## Related decisions + +This note refines only child LLM routing. The fixed subagent transport remains owned by the [subagent capability seam](2026-06-21-subagent-capability-seam.md), while the separate effect of child-scoped prompt and tool additions on fork prefix reuse remains owned by [cache-preserving forked children stay one-shot](../architecture/2026-08-10-fork-children-stay-one-shot.md). diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md new file mode 100644 index 0000000000..0dad8b9d03 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -0,0 +1,61 @@ +# Agent Note: 模型选择的 subagent 路由 + +Status: implemented + +[English](2026-08-18-model-selected-subagent-routes.md) | 中文 + +## 问题 + +`dsh-tool-subagent` 可以配置子级 `AgentOptions`,两个进程内提供方也已把这些值合并到父 Agent 的 LLM 选择之上。但面向模型的工具不能为某个适合的子任务请求不同的提供方、模型或推理强度。为每条 LLM 路由加载一个名称不同的委派工具会重复 schema,并把每次调用的调度选择变成部署配置。 + +模型还需要一种有界方式来发现实时提供方和模型自有的推理强度 ID。把 adapter 目录渲染到每一份委派描述中,会让仅供参考且会变化的目录进入 prompt 前缀。 + +## 决策 + +只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 + +提供方与模型共同组成一条路由,必须一起提供。如果配置值或父级值能够提供生效路由,则可以只提供推理强度。模型参数覆盖 `Config.agentOptions`,配置字段覆盖父 Agent 最新记录的请求选择;首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。路由不变时会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 + +显式或配置的提供方、模型或强度会在创建子级前通过 `ctx.llm.resolveCallConfig()` 解析。该查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 + +启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 + +随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 + +委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。 + +`SubagentCapabilities.agentOptions` 仍是传输事实。如果请求携带这些选项,而提供方声明为 `false`,服务会在调用提供方前拒绝。两个进程内提供方声明为 `true`;当前 ACP、Codex、Claude Code 与 DSH SDK 传输声明为 `false`。工具配置提供 `agentOptions`、静态启用模型选择或让它受 settings 控制时,如果绑定的提供方缺少该能力,也会失败。 + +## 考虑过的替代方案 + +**保留部署配置的路由允许列表。** 不采用,因为它重复实时 LLM 注册表,要求先配置才能让模型使用已经注册的路由,并为客户端增加第二套策略编辑界面。需要限制 LLM 访问的部署应控制所注册的提供方路由。 + +**在每一份委派描述中渲染实时 adapter 目录。** 不采用,因为一个提供方可能公布数百个模型,从而扩大每次请求,而且目录变化会改写缓存前缀中的早期定义。按需目录让可变数据留在固定 schema 之外。 + +**把公布的目录当作允许列表。** 不采用,因为 adapter 目录只提供建议,有些提供方接受任意精确模型 ID。精确解析仍是权威。 + +**在 subagent 服务中增加发现方法。** 不采用,因为提供方/模型/强度元数据已经属于 `ctx.llm`;新工具只是该现有能力面向模型的 Consumer。 + +**把发现工具作为独立加载的插件入口导出。** 不采用,因为随附组合总是把发现工具与主委派工具配套加载。在该实例上显式指定持有权,可以避免重复的全局工具名,无需增加 Cordis 配置项或独立生命周期。 + +**分别配置发现与面向模型的选择。** 不采用,因为发现功能用于向能够选择这些值的同一个模型提供有效的路由与强度 ID。一个开关可以避免工具 schema 公开选择却没有对应发现路径,或公开发现却没有适用的委派路由。 + +**在随附 fork 工具上启用面向模型的路由选择。** 不随产品提供,因为更改提供方或模型会失去继承前缀的 KV Cache 复用,重新预填充前缀的成本可能高于委派工作本身。只有在路由变化仍能保留复用,或接口能把这项成本显式化并限制住时,才重新考虑该选项。 + +**使用全局推理强度枚举。** 不采用,因为推理强度 ID 和默认值属于精确的提供方/模型路由。LLM adapter 会直接校验,无需中心化翻译或截断。 + +**允许远程提供方忽略这些字段。** 不采用,因为请求会声称发生了实际上没有发生的路由选择。能力标记会让不支持的路径在创建子级前失败。 + +## 结果 + +- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。 +- 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 +- 省略选择时保留配置默认值,并从父级最新记录的请求中进行兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 +- adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 +- 进程外 subagent 提供方在实现并声明该能力前,会拒绝配置和模型选择的 Agent 选项。 +- 单元测试覆盖默认关闭的 Host 偏好、新 Session 读取、子级继承、恢复决定、选择启用时的 schema 与执行强制、合并优先级、路由相关强度继承、预检取消、实时发现、诊断、定义稳定性、能力拒绝与可选服务行为。随附的 headless 快照固定从父级已记录选择继承的行为;随附示例还覆盖组装后无密钥、模型可见的 schema。 + +## 相关决策 + +本 Note 仅细化子级 LLM 路由。固定的 subagent 传输仍由 [subagent 能力 seam](2026-06-21-subagent-capability-seam.zh.md)负责,而子级作用域提示词与工具增量对 fork 前缀复用产生的独立影响仍由[保留缓存的 fork child 保持 one-shot](../architecture/2026-08-10-fork-children-stay-one-shot.zh.md)负责。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index a4e048b5fd..6af58a3a37 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,6 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' @@ -240,6 +241,34 @@ describe('the shipped Web composition', () => { } }) + it('applies the default-off subagent model-selection preference only to new sessions', async () => { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const disabled = await ctx.agents.create({ + sessionId: SessionId('preset-model-selection-disabled'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await ctx.agents.create({ + sessionId: SessionId('preset-model-selection-enabled'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') + expect(toolParameterNames(ctx, disabled.agent, 'subagent')).not.toEqual(expect.arrayContaining([ + 'model', 'provider', 'reasoning_effort', + ])) + expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models') + expect(toolParameterNames(ctx, enabled.agent, 'subagent')).toEqual(expect.arrayContaining([ + 'model', 'provider', 'reasoning_effort', + ])) + expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') + } finally { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await enabled.dispose() + await disabled.dispose() + } + }) + it('composes the exact RL prompt and two tools from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 3c3be0922c..6ac6d76796 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - status: 已保存 minimax-cn。 - list: - listitem: diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index 1d538bcfe4..2f7d4a3a30 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index df48328fd3..bb129bf2ea 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 54bf1db3c3..dfeb637b5f 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list - text: 提供方 - combobox "提供方": diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index a302932e65..020f70d095 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index 496443b057..73c66388f3 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -19,6 +19,10 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - region "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 406b4015a0..d7e1c70e5a 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 75f050f329e709e5c88bffbe0d3bc2072d4286de -capability-seams.zh.md: 25fa48c67e406b03677debba44eff5d49fd3c626 +capability-seams.md: 0994b186f7daa6afbff0f1484216e55fc79170ff +capability-seams.zh.md: 48aa0a5353bba8d56f63abfcfc4cd2c601569cb6 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 75f050f329..0994b186f7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -56,6 +56,8 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] + pkg_tool_subagent["tool-subagent"] + svc_subagentModelSelection["ctx.subagentModelSelection
Subagent model-selection preference"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -94,7 +96,6 @@ flowchart LR pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] - pkg_tool_subagent["tool-subagent"] pkg_tool_todo["tool-todo"] pkg_user_questions["user-questions"] svc_userQuestions["ctx.userQuestions
Human question/answer seam"] @@ -310,6 +311,7 @@ flowchart LR pkg_terminal --> svc_terminals pkg_terminal_bash --> svc_terminals pkg_token_meter --> svc_tokenMeter + pkg_tool_subagent --> svc_subagentModelSelection pkg_tools --> svc_tools pkg_typert_registry --> svc_typert pkg_user_questions --> svc_userQuestions @@ -401,6 +403,7 @@ flowchart LR svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace + svc_subagentModelSelection --> pkg_tool_subagent svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_subagents --> pkg_tool_subagent_control @@ -458,6 +461,7 @@ flowchart LR | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | +| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 25fa48c67e..48aa0a5353 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -58,6 +58,8 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] + pkg_tool_subagent["tool-subagent"] + svc_subagentModelSelection["ctx.subagentModelSelection
Subagent model-selection preference"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -96,7 +98,6 @@ flowchart LR pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] - pkg_tool_subagent["tool-subagent"] pkg_tool_todo["tool-todo"] pkg_user_questions["user-questions"] svc_userQuestions["ctx.userQuestions
Human question/answer seam"] @@ -312,6 +313,7 @@ flowchart LR pkg_terminal --> svc_terminals pkg_terminal_bash --> svc_terminals pkg_token_meter --> svc_tokenMeter + pkg_tool_subagent --> svc_subagentModelSelection pkg_tools --> svc_tools pkg_typert_registry --> svc_typert pkg_user_questions --> svc_userQuestions @@ -403,6 +405,7 @@ flowchart LR svc_storage --> pkg_storage_domain svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace + svc_subagentModelSelection --> pkg_tool_subagent svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_subagents --> pkg_tool_subagent_control @@ -460,6 +463,7 @@ flowchart LR | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | +| `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index eabedcfe3c..9ba399370d 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: 7f7e5d3c58953ea50c43eed0d903f0d5469e7849 -config-catalog.zh.md: f9ab7a8537e29cb0e74e05e74b4a7890d146c28b +config-catalog.md: 6494070f6204ce6130e22d6f71ddbfd18fdc69dd +config-catalog.zh.md: da19d854ca520425a799cb699238a7dca05a90da diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7f7e5d3c58..6494070f62 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -958,7 +958,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:107`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:117`](../packages/llm/llm-deepseek/src/index.ts) @@ -2841,6 +2841,14 @@ export interface Config { * a distinct name. */ toolName?: string + /** Let the model discover and select the child LLM route (default false). */ + enableModelSelection?: boolean + /** + * Sample the Host `subagent-model-selection` user setting for each new + * top-level session and inherit that decision in its child sessions. Mutually + * exclusive with `enableModelSelection`. + */ + modelSelectionSettings?: boolean /** * Expose `run_in_background` (default true). Disabled instances omit the * parameter and reject forced background calls. @@ -2888,7 +2896,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:29`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f9ab7a8537..da19d854ca 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2843,6 +2843,14 @@ export interface Config { * a distinct name. */ toolName?: string + /** Let the model discover and select the child LLM route (default false). */ + enableModelSelection?: boolean + /** + * Sample the Host `subagent-model-selection` user setting for each new + * top-level session and inherit that decision in its child sessions. Mutually + * exclusive with `enableModelSelection`. + */ + modelSelectionSettings?: boolean /** * Expose `run_in_background` (default true). Disabled instances omit the * parameter and reject forced background calls. @@ -2890,7 +2898,7 @@ export interface Config { 依赖:[`AgentOptions`](subsystems/core.zh.md) -来源:[`packages/subagent/tool-subagent/src/index.ts:29`](../packages/subagent/tool-subagent/src/index.ts) +来源:[`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index c9e637910e..33c536f50a 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: 2be5a84969b9f14823abf90cf289a0a41e48dd11 -event-producer-consumer.zh.md: 5bbae1be5d03c3e443d36093ce60dbf7e4b07971 +event-producer-consumer.md: 4ab1275e9309e150504d6a3bdd80792bb1a49ea1 +event-producer-consumer.zh.md: 8d8a401bfdccc74d5774ce5ce7ceac2c548f6c72 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2be5a84969..4ab1275e93 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,18 +9,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team` | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team` | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../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), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../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:205`](../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:186`](../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:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`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) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../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:260`](../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:217`](../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) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:292`](../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), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:199`](../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:207`](../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:188`](../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:233`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`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/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:246`](../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:262`](../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:219`](../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) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:180`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -52,13 +52,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5bbae1be5d..8d8a401bfd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,13 +11,13 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team` | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:161`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:170`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../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), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../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:205`](../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:186`](../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:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`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) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`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/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../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:260`](../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:217`](../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) | @@ -54,13 +54,13 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a7edea20b2..a467f4aea6 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: a2497ed002c6eec55ec35e1d7e9953feb2ed0ee5 -module-graph.zh.md: 230d67f4c07f9715d4b5157c1539edd09f9221c3 +module-graph.md: 9d192404f4949bcf6e5c4222f0b898cc52362bd8 +module-graph.zh.md: bbdfd5642238644939fd08c02b9786310bad63e2 diff --git a/docs/module-graph.md b/docs/module-graph.md index a2497ed002..9d192404f4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -570,14 +570,6 @@ flowchart TD pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -655,11 +647,6 @@ flowchart TD pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout - pkg_plugin_package_inventory_deepseek --> pkg_agent - pkg_plugin_package_inventory_deepseek --> pkg_agent_presets - pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions - pkg_plugin_package_inventory_deepseek --> pkg_invariants - pkg_plugin_package_inventory_deepseek --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -710,8 +697,6 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -812,21 +797,6 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets - pkg_subagent --> pkg_brand - pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs - pkg_subagent --> pkg_llm - pkg_subagent --> pkg_sandbox - pkg_subagent --> pkg_sandbox_policy - pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session - pkg_subagent --> pkg_session_persistence - pkg_subagent --> pkg_session_projection - pkg_subagent --> pkg_session_projection_cache - pkg_subagent --> pkg_tools - pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -874,10 +844,6 @@ flowchart TD pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -917,6 +883,15 @@ flowchart TD pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_agent_presets --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -990,6 +965,89 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_agent + pkg_tool_workflow --> pkg_invariants + pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session + pkg_tool_workflow --> pkg_system_prompt + pkg_tool_workflow --> pkg_tools + pkg_tool_workflow --> pkg_workflow + pkg_plugin_package_inventory_deepseek --> pkg_agent + pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions + pkg_plugin_package_inventory_deepseek --> pkg_invariants + pkg_plugin_package_inventory_deepseek --> pkg_session + pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants + pkg_subagent --> pkg_jobs + pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy + pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection + pkg_subagent --> pkg_session_projection_cache + pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval + pkg_session_query --> pkg_brand + pkg_session_query --> pkg_invariants + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_title + pkg_session_query --> pkg_tool_todo + pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment + pkg_acp --> pkg_invariants + pkg_acp --> pkg_llm + pkg_acp --> pkg_mcp_client + pkg_acp --> pkg_session + pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_token_meter + pkg_acp --> pkg_user_approval + pkg_web_app --> pkg_invariants + pkg_web_app --> pkg_shell_env + pkg_web_app --> pkg_system_prompt + pkg_compaction_tool_result_pruner --> pkg_compaction + pkg_compaction_tool_result_pruner --> pkg_invariants + pkg_compaction_tool_result_pruner --> pkg_llm + pkg_compaction_tool_result_pruner --> pkg_session + pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_tool_cordis --> pkg_agent + pkg_tool_cordis --> pkg_cordis_host_runner + pkg_tool_cordis --> pkg_invariants + pkg_tool_cordis --> pkg_llm + pkg_tool_cordis --> pkg_scope + pkg_tool_cordis --> pkg_session + pkg_tool_cordis --> pkg_system_prompt + pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_jobs + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_shell + pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_jobs + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy + pkg_tool_pwsh --> pkg_shell + pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1000,13 +1058,6 @@ flowchart TD pkg_webhook --> pkg_session pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace - pkg_tool_workflow --> pkg_agent - pkg_tool_workflow --> pkg_invariants - pkg_tool_workflow --> pkg_llm - pkg_tool_workflow --> pkg_session - pkg_tool_workflow --> pkg_system_prompt - pkg_tool_workflow --> pkg_tools - pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1037,6 +1088,9 @@ flowchart TD pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_jobs pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_scope + pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_settings pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt pkg_tool_subagent --> pkg_tools @@ -1058,107 +1112,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants - pkg_session_query --> pkg_llm - pkg_session_query --> pkg_session - pkg_session_query --> pkg_session_persistence - pkg_session_query --> pkg_session_title - pkg_session_query --> pkg_tool_todo - pkg_acp --> pkg_agent - pkg_acp --> pkg_attachment - pkg_acp --> pkg_invariants - pkg_acp --> pkg_llm - pkg_acp --> pkg_mcp_client - pkg_acp --> pkg_session - pkg_acp --> pkg_session_persistence - pkg_acp --> pkg_token_meter - pkg_acp --> pkg_user_approval - pkg_web_app --> pkg_invariants - pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_tool_todo - pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_invariants - pkg_compaction_tool_result_pruner --> pkg_llm - pkg_compaction_tool_result_pruner --> pkg_session - pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_brand - pkg_experimental_agent_team --> pkg_invariants - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_subagent - pkg_tool_cordis --> pkg_agent - pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_invariants - pkg_tool_cordis --> pkg_llm - pkg_tool_cordis --> pkg_scope - pkg_tool_cordis --> pkg_session - pkg_tool_cordis --> pkg_system_prompt - pkg_tool_cordis --> pkg_tools - pkg_sdk_protocol --> pkg_invariants - pkg_sdk_protocol --> pkg_llm - pkg_sdk_protocol --> pkg_session - pkg_sdk_protocol --> pkg_subagent - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_jobs - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_shell - pkg_tool_bash --> pkg_shell_env - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_invariants - pkg_tool_pwsh --> pkg_jobs - pkg_tool_pwsh --> pkg_llm - pkg_tool_pwsh --> pkg_sandbox - pkg_tool_pwsh --> pkg_sandbox_policy - pkg_tool_pwsh --> pkg_shell - pkg_tool_pwsh --> pkg_shell_env - pkg_tool_pwsh --> pkg_system_prompt - pkg_tool_pwsh --> pkg_tools - pkg_tool_pwsh --> pkg_user_approval - pkg_webhook_github --> pkg_credentials - pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants - pkg_webhook_github --> pkg_session - pkg_webhook_github --> pkg_webhook - pkg_tool_ralph --> pkg_agent - pkg_tool_ralph --> pkg_invariants - pkg_tool_ralph --> pkg_llm - pkg_tool_ralph --> pkg_subagent - pkg_tool_ralph --> pkg_system_prompt - pkg_tool_ralph --> pkg_tools - pkg_tool_ralph --> pkg_workflow - pkg_workflow_worker_thread --> pkg_agent - pkg_workflow_worker_thread --> pkg_brand - pkg_workflow_worker_thread --> pkg_invariants - pkg_workflow_worker_thread --> pkg_llm - pkg_workflow_worker_thread --> pkg_session - pkg_workflow_worker_thread --> pkg_subagent - pkg_workflow_worker_thread --> pkg_tools - pkg_workflow_worker_thread --> pkg_workflow - pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_invariants - pkg_subagent_fork_in_process --> pkg_session - pkg_subagent_fork_in_process --> pkg_subagent - pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_invariants - pkg_subagent_spawn_in_process --> pkg_subagent - pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1170,11 +1123,14 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1213,6 +1169,54 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_agent_team --> pkg_agent + pkg_experimental_agent_team --> pkg_brand + pkg_experimental_agent_team --> pkg_invariants + pkg_experimental_agent_team --> pkg_llm + pkg_experimental_agent_team --> pkg_session + pkg_experimental_agent_team --> pkg_session_persistence + pkg_experimental_agent_team --> pkg_subagent + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants + pkg_sdk_protocol --> pkg_invariants + pkg_sdk_protocol --> pkg_llm + pkg_sdk_protocol --> pkg_session + pkg_sdk_protocol --> pkg_subagent + pkg_webhook_github --> pkg_credentials + pkg_webhook_github --> pkg_host_webserver + pkg_webhook_github --> pkg_invariants + pkg_webhook_github --> pkg_session + pkg_webhook_github --> pkg_webhook + pkg_tool_ralph --> pkg_agent + pkg_tool_ralph --> pkg_invariants + pkg_tool_ralph --> pkg_llm + pkg_tool_ralph --> pkg_subagent + pkg_tool_ralph --> pkg_system_prompt + pkg_tool_ralph --> pkg_tools + pkg_tool_ralph --> pkg_workflow + pkg_workflow_worker_thread --> pkg_agent + pkg_workflow_worker_thread --> pkg_brand + pkg_workflow_worker_thread --> pkg_invariants + pkg_workflow_worker_thread --> pkg_llm + pkg_workflow_worker_thread --> pkg_session + pkg_workflow_worker_thread --> pkg_subagent + pkg_workflow_worker_thread --> pkg_tools + pkg_workflow_worker_thread --> pkg_workflow + pkg_subagent_fork_in_process --> pkg_agent + pkg_subagent_fork_in_process --> pkg_invariants + pkg_subagent_fork_in_process --> pkg_session + pkg_subagent_fork_in_process --> pkg_subagent + pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver + pkg_subagent_spawn_in_process --> pkg_invariants + pkg_subagent_spawn_in_process --> pkg_subagent + pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1731,7 +1735,6 @@ flowchart TD | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1747,7 +1750,6 @@ flowchart TD | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1760,7 +1762,6 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1777,7 +1778,6 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1786,7 +1786,6 @@ flowchart TD | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1794,6 +1793,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1807,37 +1807,41 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 230d67f4c0..bbdfd56422 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -572,14 +572,6 @@ flowchart TD pkg_jobs --> pkg_brand pkg_jobs --> pkg_invariants pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox @@ -657,11 +649,6 @@ flowchart TD pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout - pkg_plugin_package_inventory_deepseek --> pkg_agent - pkg_plugin_package_inventory_deepseek --> pkg_agent_presets - pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions - pkg_plugin_package_inventory_deepseek --> pkg_invariants - pkg_plugin_package_inventory_deepseek --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -712,8 +699,6 @@ flowchart TD pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session pkg_command_feedback --> pkg_session_telemetry - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -814,21 +799,6 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_subagent --> pkg_agent - pkg_subagent --> pkg_agent_presets - pkg_subagent --> pkg_brand - pkg_subagent --> pkg_invariants - pkg_subagent --> pkg_jobs - pkg_subagent --> pkg_llm - pkg_subagent --> pkg_sandbox - pkg_subagent --> pkg_sandbox_policy - pkg_subagent --> pkg_scope - pkg_subagent --> pkg_session - pkg_subagent --> pkg_session_persistence - pkg_subagent --> pkg_session_projection - pkg_subagent --> pkg_session_projection_cache - pkg_subagent --> pkg_tools - pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -876,10 +846,6 @@ flowchart TD pkg_file_reference_local --> pkg_invariants pkg_file_reference_local --> pkg_system_prompt pkg_file_reference_local --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -919,6 +885,15 @@ flowchart TD pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_agent_presets --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_brand pkg_schedule --> pkg_invariants @@ -992,6 +967,89 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_workflow --> pkg_agent + pkg_tool_workflow --> pkg_invariants + pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session + pkg_tool_workflow --> pkg_system_prompt + pkg_tool_workflow --> pkg_tools + pkg_tool_workflow --> pkg_workflow + pkg_plugin_package_inventory_deepseek --> pkg_agent + pkg_plugin_package_inventory_deepseek --> pkg_agent_presets + pkg_plugin_package_inventory_deepseek --> pkg_deepseek_llm_api_extensions + pkg_plugin_package_inventory_deepseek --> pkg_invariants + pkg_plugin_package_inventory_deepseek --> pkg_session + pkg_subagent --> pkg_agent + pkg_subagent --> pkg_agent_presets + pkg_subagent --> pkg_brand + pkg_subagent --> pkg_invariants + pkg_subagent --> pkg_jobs + pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy + pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session + pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection + pkg_subagent --> pkg_session_projection_cache + pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval + pkg_session_query --> pkg_brand + pkg_session_query --> pkg_invariants + pkg_session_query --> pkg_llm + pkg_session_query --> pkg_session + pkg_session_query --> pkg_session_persistence + pkg_session_query --> pkg_session_title + pkg_session_query --> pkg_tool_todo + pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment + pkg_acp --> pkg_invariants + pkg_acp --> pkg_llm + pkg_acp --> pkg_mcp_client + pkg_acp --> pkg_session + pkg_acp --> pkg_session_persistence + pkg_acp --> pkg_token_meter + pkg_acp --> pkg_user_approval + pkg_web_app --> pkg_invariants + pkg_web_app --> pkg_shell_env + pkg_web_app --> pkg_system_prompt + pkg_compaction_tool_result_pruner --> pkg_compaction + pkg_compaction_tool_result_pruner --> pkg_invariants + pkg_compaction_tool_result_pruner --> pkg_llm + pkg_compaction_tool_result_pruner --> pkg_session + pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_tool_cordis --> pkg_agent + pkg_tool_cordis --> pkg_cordis_host_runner + pkg_tool_cordis --> pkg_invariants + pkg_tool_cordis --> pkg_llm + pkg_tool_cordis --> pkg_scope + pkg_tool_cordis --> pkg_session + pkg_tool_cordis --> pkg_system_prompt + pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants + pkg_tool_bash --> pkg_agent + pkg_tool_bash --> pkg_invariants + pkg_tool_bash --> pkg_jobs + pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_sandbox + pkg_tool_bash --> pkg_sandbox_policy + pkg_tool_bash --> pkg_shell + pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_system_prompt + pkg_tool_bash --> pkg_tools + pkg_tool_bash --> pkg_user_approval + pkg_tool_pwsh --> pkg_agent + pkg_tool_pwsh --> pkg_invariants + pkg_tool_pwsh --> pkg_jobs + pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy + pkg_tool_pwsh --> pkg_shell + pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_system_prompt + pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1002,13 +1060,6 @@ flowchart TD pkg_webhook --> pkg_session pkg_webhook --> pkg_session_title pkg_webhook --> pkg_workspace - pkg_tool_workflow --> pkg_agent - pkg_tool_workflow --> pkg_invariants - pkg_tool_workflow --> pkg_llm - pkg_tool_workflow --> pkg_session - pkg_tool_workflow --> pkg_system_prompt - pkg_tool_workflow --> pkg_tools - pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1039,6 +1090,9 @@ flowchart TD pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_jobs pkg_tool_subagent --> pkg_llm + pkg_tool_subagent --> pkg_scope + pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_settings pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt pkg_tool_subagent --> pkg_tools @@ -1060,107 +1114,6 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_session_query --> pkg_brand - pkg_session_query --> pkg_invariants - pkg_session_query --> pkg_llm - pkg_session_query --> pkg_session - pkg_session_query --> pkg_session_persistence - pkg_session_query --> pkg_session_title - pkg_session_query --> pkg_tool_todo - pkg_acp --> pkg_agent - pkg_acp --> pkg_attachment - pkg_acp --> pkg_invariants - pkg_acp --> pkg_llm - pkg_acp --> pkg_mcp_client - pkg_acp --> pkg_session - pkg_acp --> pkg_session_persistence - pkg_acp --> pkg_token_meter - pkg_acp --> pkg_user_approval - pkg_web_app --> pkg_invariants - pkg_web_app --> pkg_shell_env - pkg_web_app --> pkg_system_prompt - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_tool_todo - pkg_compaction_tool_result_pruner --> pkg_compaction - pkg_compaction_tool_result_pruner --> pkg_invariants - pkg_compaction_tool_result_pruner --> pkg_llm - pkg_compaction_tool_result_pruner --> pkg_session - pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_brand - pkg_experimental_agent_team --> pkg_invariants - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_subagent - pkg_tool_cordis --> pkg_agent - pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_invariants - pkg_tool_cordis --> pkg_llm - pkg_tool_cordis --> pkg_scope - pkg_tool_cordis --> pkg_session - pkg_tool_cordis --> pkg_system_prompt - pkg_tool_cordis --> pkg_tools - pkg_sdk_protocol --> pkg_invariants - pkg_sdk_protocol --> pkg_llm - pkg_sdk_protocol --> pkg_session - pkg_sdk_protocol --> pkg_subagent - pkg_tool_bash --> pkg_agent - pkg_tool_bash --> pkg_invariants - pkg_tool_bash --> pkg_jobs - pkg_tool_bash --> pkg_llm - pkg_tool_bash --> pkg_sandbox - pkg_tool_bash --> pkg_sandbox_policy - pkg_tool_bash --> pkg_shell - pkg_tool_bash --> pkg_shell_env - pkg_tool_bash --> pkg_system_prompt - pkg_tool_bash --> pkg_tools - pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_agent - pkg_tool_pwsh --> pkg_invariants - pkg_tool_pwsh --> pkg_jobs - pkg_tool_pwsh --> pkg_llm - pkg_tool_pwsh --> pkg_sandbox - pkg_tool_pwsh --> pkg_sandbox_policy - pkg_tool_pwsh --> pkg_shell - pkg_tool_pwsh --> pkg_shell_env - pkg_tool_pwsh --> pkg_system_prompt - pkg_tool_pwsh --> pkg_tools - pkg_tool_pwsh --> pkg_user_approval - pkg_webhook_github --> pkg_credentials - pkg_webhook_github --> pkg_host_webserver - pkg_webhook_github --> pkg_invariants - pkg_webhook_github --> pkg_session - pkg_webhook_github --> pkg_webhook - pkg_tool_ralph --> pkg_agent - pkg_tool_ralph --> pkg_invariants - pkg_tool_ralph --> pkg_llm - pkg_tool_ralph --> pkg_subagent - pkg_tool_ralph --> pkg_system_prompt - pkg_tool_ralph --> pkg_tools - pkg_tool_ralph --> pkg_workflow - pkg_workflow_worker_thread --> pkg_agent - pkg_workflow_worker_thread --> pkg_brand - pkg_workflow_worker_thread --> pkg_invariants - pkg_workflow_worker_thread --> pkg_llm - pkg_workflow_worker_thread --> pkg_session - pkg_workflow_worker_thread --> pkg_subagent - pkg_workflow_worker_thread --> pkg_tools - pkg_workflow_worker_thread --> pkg_workflow - pkg_subagent_fork_in_process --> pkg_agent - pkg_subagent_fork_in_process --> pkg_invariants - pkg_subagent_fork_in_process --> pkg_session - pkg_subagent_fork_in_process --> pkg_subagent - pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver - pkg_subagent_spawn_in_process --> pkg_invariants - pkg_subagent_spawn_in_process --> pkg_subagent - pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1172,11 +1125,14 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1215,6 +1171,54 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_agent_team --> pkg_agent + pkg_experimental_agent_team --> pkg_brand + pkg_experimental_agent_team --> pkg_invariants + pkg_experimental_agent_team --> pkg_llm + pkg_experimental_agent_team --> pkg_session + pkg_experimental_agent_team --> pkg_session_persistence + pkg_experimental_agent_team --> pkg_subagent + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants + pkg_sdk_protocol --> pkg_invariants + pkg_sdk_protocol --> pkg_llm + pkg_sdk_protocol --> pkg_session + pkg_sdk_protocol --> pkg_subagent + pkg_webhook_github --> pkg_credentials + pkg_webhook_github --> pkg_host_webserver + pkg_webhook_github --> pkg_invariants + pkg_webhook_github --> pkg_session + pkg_webhook_github --> pkg_webhook + pkg_tool_ralph --> pkg_agent + pkg_tool_ralph --> pkg_invariants + pkg_tool_ralph --> pkg_llm + pkg_tool_ralph --> pkg_subagent + pkg_tool_ralph --> pkg_system_prompt + pkg_tool_ralph --> pkg_tools + pkg_tool_ralph --> pkg_workflow + pkg_workflow_worker_thread --> pkg_agent + pkg_workflow_worker_thread --> pkg_brand + pkg_workflow_worker_thread --> pkg_invariants + pkg_workflow_worker_thread --> pkg_llm + pkg_workflow_worker_thread --> pkg_session + pkg_workflow_worker_thread --> pkg_subagent + pkg_workflow_worker_thread --> pkg_tools + pkg_workflow_worker_thread --> pkg_workflow + pkg_subagent_fork_in_process --> pkg_agent + pkg_subagent_fork_in_process --> pkg_invariants + pkg_subagent_fork_in_process --> pkg_session + pkg_subagent_fork_in_process --> pkg_subagent + pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver + pkg_subagent_spawn_in_process --> pkg_invariants + pkg_subagent_spawn_in_process --> pkg_subagent + pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1642,7 +1646,7 @@ flowchart TD pkg_client_ui_cordis --> pkg_invariants ``` -| Package | Group | Depends on | +| 包 | 分组 | 依赖 | | --- | --- | --- | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1733,7 +1737,6 @@ flowchart TD | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1749,7 +1752,6 @@ flowchart TD | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1762,7 +1764,6 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1779,7 +1780,6 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1788,7 +1788,6 @@ flowchart TD | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1796,6 +1795,7 @@ flowchart TD | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1809,37 +1809,41 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | -| [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | -| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 06c60504dc..aad7db87f6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: 5155968af0886a389d0b01d9332af927cff95a55 -persistence-catalog.zh.md: abd4ae767a5cfca45be76b54d392815244070869 +persistence-catalog.md: dd2124520e43e590fc3506b23c533b3e482132cd +persistence-catalog.zh.md: 48c0867f37fc87ce5d29ce04b0b6970fca83648c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5155968af0..dd2124520e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -726,7 +726,23 @@ Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) + + + +#### `subagent/model-selection-enabled` — log-only + +```ts persistence-catalog +/** + * Records that this session's delegation tool exposes child provider, + * model, and reasoning-effort selection. Appended before the first model + * request; absence means the fixed-route definition. Log-only: it carries + * no `surfaceOp` and never enters model history. + */ +'subagent/model-selection-enabled': Record +``` + +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index abd4ae767a..48c0867f37 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -728,7 +728,23 @@ export type SessionEvent = { 'subagent/descriptor': SubagentDescriptorData ``` -来源:[`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) +来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) + + + +#### `subagent/model-selection-enabled` — log-only + +```ts persistence-catalog +/** + * Records that this session's delegation tool exposes child provider, + * model, and reasoning-effort selection. Appended before the first model + * request; absence means the fixed-route definition. Log-only: it carries + * no `surfaceOp` and never enters model history. + */ +'subagent/model-selection-enabled': Record +``` + +来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 9174e27772..fb5dd11fbb 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: 3417560539f41009a290f4c31b255f338624d56d -core.zh.md: 75f3c0ca16e1235cb2155f6e54e86e414e61b498 +core.md: c53bb94fa5918c3a91ee9aedbb2416d0b101b240 +core.zh.md: 93ee45fb88cc100eb77673f2b70e86483c7ed29f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 3417560539..c53bb94fa5 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -161,12 +161,14 @@ interface AgentOptions { provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string + /** Adapter-owned reasoning effort for the selected provider/model route. */ + reasoningEffort?: ReasoningEffortId /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } ``` -Dispatch requires `provider` and `model` after `agent/request`. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona` prompt section may shadow the global default persona. +Dispatch requires `provider` and `model` after `agent/request`. An explicit `reasoningEffort` seeds the first request on that route; exact-model resolution validates it, while omission allows the adapter default to materialize. When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. An agent-scoped `deployment:persona` prompt section may shadow the global default persona. The inbox is the delivery vocabulary — two ordered pending-message lists the agent owns as a durable projection: @@ -524,7 +526,9 @@ serviceFor(agent: { ctx: Context }, name: K): * state to restore. The re-link runs through the binding this roster kept * from the agent's mount — dsh-scope's only re-link authority. An agent * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. + * agent's first bind, exactly a mount. A committed re-link emits + * `tools/change` because changing the parent scope changes the Agent's + * resolved tool set without adding or removing registry entries. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 75f3c0ca16..93ee45fb88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -165,12 +165,14 @@ interface AgentOptions { provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string + /** Adapter-owned reasoning effort for the selected provider/model route. */ + reasoningEffort?: ReasoningEffortId /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } ``` -在 `agent/request` 之后,分发要求 `provider` 与 `model` 都存在。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona` 提示词段落可以遮蔽全局默认 persona。 +在 `agent/request` 之后,分发要求 `provider` 与 `model` 都存在。显式 `reasoningEffort` 会为该路由的首次请求提供初始值;确切模型解析会校验该值,省略时则允许填入适配器默认值。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。agent 作用域的 `deployment:persona` 提示词段落可以遮蔽全局默认 persona。 inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待处理消息列表: @@ -534,7 +536,9 @@ serviceFor(agent: { ctx: Context }, name: K): * state to restore. The re-link runs through the binding this roster kept * from the agent's mount — dsh-scope's only re-link authority. An agent * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. + * agent's first bind, exactly a mount. A committed re-link emits + * `tools/change` because changing the parent scope changes the Agent's + * resolved tool set without adding or removing registry entries. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index ac6150111a..cd3f9d99c9 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: 7a0ba28afbbec88465415b3411946a0afa0da918 -subagent.zh.md: f397c19a0dc79c0c5a94e617e2013b68faa4d2e9 +subagent.md: 63edef0a4b8d5368ea9d6d82f0ea3ef99ea0bbad +subagent.zh.md: 21b0dfd21dbee5e1d37558d6c02fe7949126d9a9 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 7a0ba28afb..63edef0a4b 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -25,6 +25,7 @@ A provider advertises its **start-time** features on a static descriptor the ser * to `maxDepth`; the other names match. */ interface SubagentCapabilities { + readonly agentOptions: boolean readonly outputSchema: boolean readonly depthLimit: boolean readonly toolFilter: boolean @@ -34,7 +35,7 @@ interface SubagentCapabilities { ## The one-shot start request -The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. +The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional Agent provider, model, reasoning-effort, and token overrides, output schema, depth, tool filter, and persona require matching capability flags. In-process backends merge `agentOptions` over the parent Agent's options, scope filters and personas to child creation, and implement the supported object-rooted schema with a forced capture tool. Current out-of-process providers reject `agentOptions` before starting their transport. ```ts type-equiv /** @@ -63,6 +64,12 @@ interface SubagentStartRequest { * remaining turn work when it fires afterward. */ readonly signal: AbortSignal + /** + * Optional host-Agent provider, model, reasoning-effort, and output-token + * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process + * providers merge them over the parent Agent's options when they create the + * child. + */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -280,7 +287,7 @@ interface ContinuableCreateSpec { } ``` -The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity). +The descriptor (`SubagentDescriptorData` in [descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts)) is a mode-discriminated durable identity for every session-backed subagent. Both modes carry the provider name. A `one-shot` descriptor optionally carries a caller-owned display `label`; a `continuable` descriptor requires the delegation `description` as its durable creation label and additionally snapshots resolved child `agentOptions.provider`/`model`/`reasoningEffort` and optional `persona`/`toolFilter` for cold resume. It never snapshots the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation and a later composition input is a deliberate version change. It omits `subagentDepth` (cold resume trusts the persisted header's `delegationDepth` as the monotone floor) and `outputSchema` (one run or Activation's result contract, not durable identity). A local one-shot provider appends the descriptor inside the child's initial turn before its first request. The continuation manager appends the descriptor after any provider-supplied lineage and before the initial prompt is admitted; `header.seedLength` remains the fork-lineage boundary: resume-time descriptor authority reads the child's own suffix, while the list-serving identity projection folds `subagent/descriptor` last-wins so the child's own descriptor overrides a fork-seeded ancestor's. The event is log-only: no `surfaceOp`, never in model history, and retained across compaction by the append-only log. Malformed current-version descriptors are corrupt; unsupported versions cannot be classified by this runtime. @@ -482,6 +489,22 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` + +Singleton settings owner read by delegation tools when an Agent is published. + +```ts cordis-catalog +/** + * Read the preference for the next eligible Agent publication. + * @returns whether that Agent should receive model-selectable delegation. + */ +currentEnabled(): boolean +``` + +Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) + ### `ctx.subagents` — `SubagentRuntime` diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index f397c19a0d..21b0dfd21d 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -25,6 +25,7 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub * to `maxDepth`; the other names match. */ interface SubagentCapabilities { + readonly agentOptions: boolean readonly outputSchema: boolean readonly depthLimit: boolean readonly toolFilter: boolean @@ -34,7 +35,7 @@ interface SubagentCapabilities { ## 单次启动请求 -工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。不支持的 schema 在启动时即失败;进程内后端将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。 +工具层根据模型输入和自身配置构建此请求;服务在 `start` 之前针对指定提供方进行校验。必填的 `parent` 提供会话 cwd、谱系与委派深度。可选的 Agent 提供方、模型、推理强度与 token 覆盖、output schema、depth、工具过滤器和 persona 需要对应的能力 flag 匹配。进程内后端会把 `agentOptions` 合并到父 Agent 选项之上,将 filter 和 persona 的作用域限定在子 agent 创建阶段,并通过强制 capture 工具实现所支持的 object-rooted schema。当前进程外提供方会在启动其传输前拒绝 `agentOptions`。 ```ts type-equiv /** @@ -63,6 +64,12 @@ interface SubagentStartRequest { * remaining turn work when it fires afterward. */ readonly signal: AbortSignal + /** + * Optional host-Agent provider, model, reasoning-effort, and output-token + * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process + * providers merge them over the parent Agent's options when they create the + * child. + */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects @@ -280,7 +287,7 @@ interface ContinuableCreateSpec { } ``` -描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果约定,而非持久化身份)。 +描述符([descriptor.ts](../../packages/subagent/subagent/src/descriptor.ts) 中的 `SubagentDescriptorData`)是每个由会话支撑的 subagent 所使用、按模式判别的持久化身份。两种模式都携带提供方名称。`one-shot` 描述符可以携带调用方拥有的可选显示 `label`;`continuable` 描述符要求以委派 `description` 作为持久化创建标签,并另外对已解析的子 agent `agentOptions.provider`/`model`/`reasoningEffort` 与可选的 `persona`/`toolFilter` 建立快照,用于冷恢复。它绝不会对可合并扩展的 `AgentOptions` 对象建立快照,因此无关的扩展值不会破坏继续执行,后续新增组合配置输入则是一次有意的版本更改。描述符省略 `subagentDepth`(冷恢复以持久化 header 中的 `delegationDepth` 作为单调下界)和 `outputSchema`(单次运行或 Activation 的结果约定,而非持久化身份)。 本地一次性提供方会在子 agent 的初始轮次内、首次请求前追加描述符。继续执行管理器会在任何提供方提供的谱系之后、初始提示词获准之前追加描述符;`header.seedLength` 仍是 fork 谱系边界:恢复时的描述符权威读取子 agent 自身的后缀,而供列表使用的身份投影以 last-wins 折叠 `subagent/descriptor`,子 agent 自己的描述符会覆盖 fork seed 中祖先的描述符。该事件只进入日志:不含 `surfaceOp`,绝不进入模型历史,并由仅追加日志跨压缩保留。格式错误的当前版本描述符属于损坏;本运行时无法对不受支持的版本进行分类。 @@ -486,6 +493,22 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` + +Singleton settings owner read by delegation tools when an Agent is published. + +```ts cordis-catalog +/** + * Read the preference for the next eligible Agent publication. + * @returns whether that Agent should receive model-selectable delegation. + */ +currentEnabled(): boolean +``` + +Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) + ### `ctx.subagents` — `SubagentRuntime` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 10447d6107..38ad384da8 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: 1fa650f1e4e025274d069f27a6522abff46af2e2 -tool-catalog.zh.md: c3209e7007e9cf05770ccee0698f9e98a32e8363 +tool-catalog.md: 0cd8560a6851f0195e2272d1bd3b0bec2c171ac4 +tool-catalog.zh.md: cb225bc11afa6a6b022f2c7c104d4e1286f89260 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1fa650f1e4..0cd8560a68 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `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` | - | 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-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance's description, `run_in_background` parameter, and system-prompt policy follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable` and defaults omitted calls to background with automatic settlement delivery, while `subagent_fork` stays `one-shot` and defaults them to foreground — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@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, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -1501,9 +1501,31 @@ The five read-only tools hide provider cursors and authorize every result from t ## `@deepseek-ai/dsh-tool-subagent` +### `list_subagent_models` + +Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. + +```json +{ + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } +} +``` + +Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subagent/tool-subagent/src/list-models.ts) + ### `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. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. +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. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. ```json { @@ -1517,6 +1539,18 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1531,7 +1565,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 registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance's description, `run_in_background` parameter, and system-prompt policy follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable` and defaults omitted calls to background with automatic settlement delivery, while `subagent_fork` stays `one-shot` and defaults them to foreground — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index c3209e7007..cb225bc11a 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `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-tool-subagent` | `subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的组合会为每个 subagent 后端加载一次该包,因此模型还会看到绑定到 fork 后端的 `subagent_fork`。每个实例的描述、`run_in_background` 参数与 system prompt 策略取决于它自己的 `backgroundMode` 和 `enableRunInBackground`,因此两个随附 schema 并不相同:`subagent` 为 `continuable`,省略参数时默认后台运行,并由 runtime 自动投递结束结果;`subagent_fork` 保持 `one-shot`,省略参数时默认前台运行。详见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@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 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -1507,9 +1507,31 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ## `@deepseek-ai/dsh-tool-subagent` +### `list_subagent_models` + +发现 subagent 可用的 LLM 路由,不更改当前 Agent。无参数调用会列出已注册提供方;提供 `provider` 时会列出其公布的模型;同时提供 `provider` 和 `model` 时会检查该精确模型及其推理强度。目录条目只提供建议:adapter 可能接受未列出的模型 id。把返回的 id 用于委派工具的 `provider`、`model` 与 `reasoning_effort` 字段。 + +```json +{ + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } +} +``` + +来源:[`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subagent/tool-subagent/src/list-models.ts) + ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。 ```json { @@ -1523,6 +1545,18 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1537,7 +1571,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 对应默认值。随产品发布的组合会为每个 subagent 后端加载一次该包,因此模型还会看到绑定到 fork 后端的 `subagent_fork`。每个实例的描述、`run_in_background` 参数与 system prompt 策略取决于它自己的 `backgroundMode` 和 `enableRunInBackground`,因此两个随附 schema 并不相同:`subagent` 为 `continuable`,省略参数时默认后台运行,并由 runtime 自动投递结束结果;`subagent_fork` 保持 `one-shot`,省略参数时默认前台运行。详见 `packages/bundle/base/cordis.patch.yml` 和 `examples/acp-agent/cordis.yml`。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index ddfb914132..8f1df1955d 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -54,9 +54,17 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable maxDepth: 1 +# Fork omits model selection so provider/model stay equal to the parent and the +# inherited history remains eligible for KV Cache reuse. It stays one-shot because +# a continuable child's `report` tool and prompt section precede that history and +# invalidate the same prefix. `run_in_background` is off as an explicit foreground-only +# choice even though the shipped ACP profile mounts the generic Job runtime. +# See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +# and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 89ef8c1035..245253690f 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -20,6 +20,7 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable maxDepth: 2 # Re-pin the recorded flash model for this scenario's corpus. diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml index b2d4dbe039..d25b48fb60 100644 --- a/examples/acp-agent/depth-two.cordis.yml +++ b/examples/acp-agent/depth-two.cordis.yml @@ -5,5 +5,6 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable maxDepth: 2 diff --git a/examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml b/examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml new file mode 100644 index 0000000000..c89a77d49d --- /dev/null +++ b/examples/acp-agent/subagent-configured-effort.cordis.snapshot.yml @@ -0,0 +1,65 @@ +# Keyless counterpart to subagent-configured-effort.cordis.yml: disable the +# live adapter, insert replay, and apply the configured-effort rejection patch. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + enableModelSelection: true + backgroundMode: continuable + maxDepth: 1 + agentOptions: + provider: deepseek-official + model: deepseek-v4-flash + reasoningEffort: unsupported + +- id: acp + name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-flash + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + compression: none + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/subagent-configured-effort.cordis.yml b/examples/acp-agent/subagent-configured-effort.cordis.yml new file mode 100644 index 0000000000..9042fd3d91 --- /dev/null +++ b/examples/acp-agent/subagent-configured-effort.cordis.yml @@ -0,0 +1,15 @@ +# Configured-effort rejection snapshot overlay: keep one invalid configured +# effort so the tool rejects before starting a child instead of deferring the +# failure to the child agent loop. +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + enableModelSelection: true + backgroundMode: continuable + maxDepth: 1 + agentOptions: + provider: deepseek-official + model: deepseek-v4-flash + reasoningEffort: unsupported diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index f6bd6124e7..73286b97a6 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -70,6 +70,9 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( const SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG = fileURLToPath( new URL('../subagent-continuable-inheritance.cordis.yml', import.meta.url), ) +const SUBAGENT_CONFIGURED_EFFORT_CONFIG = fileURLToPath( + new URL('../subagent-configured-effort.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) @@ -201,6 +204,10 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, overridden: true, + pinsHeader: true, + headerClass: 'session-title', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', configPath: SESSION_TITLE_CONFIG, }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, @@ -402,8 +409,8 @@ const SCENARIOS: Scenario[] = [ // An overwrite whose replacement is at/above the configured diff-basis bound: // the persisted result meta carries no contextual hunks and presentation // falls back to the whole-file diff. The overlay leaves the prompt and tool - // sequence identical to text-turn, but the freshly recorded header carries - // the current adapter capability fields, so the scenario pins its own class. + // sequence identical to text-turn. The scenario pins its own header class + // for config fields while sharing the unchanged prompt and tool schema. { name: 'fs-write-overwrite-bounded', hasModelTurn: true, @@ -559,6 +566,20 @@ const SCENARIOS: Scenario[] = [ overridden: true, configPath: DEPTH_TWO_CONFIG, }, + // Authored keyless replay first discovers the exact child route through the + // assembled directory tool, then proves a configured effort is validated + // before child creation through the same live replay LLM registry. + { + name: 'subagent-configured-effort-rejection', + hasModelTurn: true, + recorded: false, + overridden: true, + pinsHeader: true, + headerClass: 'subagent-configured-effort', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', + configPath: SUBAGENT_CONFIGURED_EFFORT_CONFIG, + }, // Authored keyless replay through the assembled app: a one-shot child calls // the real ask_user_question tool, the runtime-ownership guard rejects before // the tripwire provider, and the child carries the unresolved decision in its diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 01ba355631..607c9b3bb3 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"e3c23441-606f-4e7a-8338-b434c0d04a4e"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"b1814e62-f9de-49fc-8e60-4271eecb3500"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 73958c517c..d3cbf0e856 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"5d128e81-c7c2-4cd0-ad1c-7409b33650fc"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"f82215a6-9c52-4c75-b46b-f722a1b64f72"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 1743643d95..b880f67453 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -301,6 +301,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -351,12 +358,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -587,6 +600,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index dcce863f94..2f1950a691 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -457,6 +457,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -627,7 +644,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -639,6 +656,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 37df6287ed..b667c8dd6b 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -134,6 +134,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -184,12 +191,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -401,6 +414,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index b8a2ed790f..bf85198220 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -430,7 +447,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -442,6 +459,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index daf622df60..c9bad7d1fa 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -136,6 +136,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -186,12 +193,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -403,6 +416,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 6894f13fb6..7506ad8373 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -136,6 +136,13 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; + /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ + list_subagent_models: { + /** Registered LLM provider id. Omit to list providers. */ + provider?: string; + /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ + model?: string; + } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -186,12 +193,18 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[]; } & Record; - /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ + /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; + /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ + provider?: string; + /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ + model?: string; + /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ + reasoning_effort?: string; /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; @@ -403,6 +416,7 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; + list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json index 993a7579bd..2819e54870 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/tool-schemas.expected.json @@ -180,6 +180,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -313,7 +330,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -325,6 +342,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 4ea490a884..c012852f0a 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "lsp", "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", @@ -446,7 +463,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -458,6 +475,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json index e1d954e615..5eec9bb706 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json index 944a002e53..2d5b27c48b 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json index 0ed6e087db..bb5b4b7411 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 6ab4f41978..2303325732 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json index c423cdb57c..7ff41194b3 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -613,7 +630,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -625,6 +642,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl index 323cdcdb4c..e6f032662c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"d8734c8a-d956-4e3f-8d28-399adf51a203"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json index fdda53355f..6aa10aa7d5 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/tool-schemas.expected.json @@ -323,6 +323,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -472,7 +489,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -484,6 +501,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json new file mode 100644 index 0000000000..eb1c2ee7ff --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json new file mode 100644 index 0000000000..0ac208bb71 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/replay.override.json @@ -0,0 +1,32 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_list_child_model", "name": "list_subagent_models", "argumentsDelta": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_list_child_model", "name": "list_subagent_models", "arguments": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_configured_effort", "name": "subagent", "argumentsDelta": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_configured_effort", "name": "subagent", "arguments": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "CONFIGURED_EFFORT_REJECTED" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "CONFIGURED_EFFORT_REJECTED" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl new file mode 100644 index 0000000000..25f746a2c1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/session.jsonl @@ -0,0 +1,41 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"04d53124-d38b-4705-a711-d0b5755b74c9"}]}} +{"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":"user/message","data":{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"04d53124-d38b-4705-a711-d0b5755b74c9"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"55365caf-6fcc-484b-a4b7-646914654bbc"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Inspect the configured child model,","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list_child_model","name":"list_subagent_models","argumentsDelta":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"000adf36-9918-4ad3-ab38-2fdb9003d0d7"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_list_child_model"},"content":[{"type":"tool-result","toolCallId":"call_list_child_model","content":[{"type":"text","text":"deepseek-official/deepseek-v4-flash — deepseek-v4-flash\nReasoning efforts:\n(no advertised reasoning efforts)"}],"isError":false}],"role":"user","id":"f480c34c-cd56-4b8e-be7c-e6ce50d3f70a"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_configured_effort","name":"subagent","argumentsDelta":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aec67548-de79-480f-8aec-2bd458ddbb41"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_configured_effort"},"content":[{"type":"tool-result","toolCallId":"call_configured_effort","content":[{"type":"text","text":"Error: provider \"deepseek-official\" model \"deepseek-v4-flash\" does not support reasoning effort \"unsupported\""}],"isError":true}],"role":"user","id":"9a59cf14-e222-40de-85f4-6a4ab4f5a177"},"error":{"name":"LlmError","code":"UNSUPPORTED_REASONING_EFFORT"}},"sourceEventSeqs":[28],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"step/start","data":{"turn":1,"step":3}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CONFIGURED_EFFORT_REJECTED"}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458f-b802-4a66221ec047"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":3}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl new file mode 100644 index 0000000000..7d5829fb9d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-configured-effort-rejection/stdout.expected.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_list_child_model","title":"list_subagent_models","kind":"other","status":"in_progress","rawInput":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_list_child_model","status":"completed","content":[{"type":"content","content":{"type":"text","text":"deepseek-official/deepseek-v4-flash — deepseek-v4-flash\nReasoning efforts:\n(no advertised reasoning efforts)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_configured_effort","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Validate configured effort","prompt":"This child must never start.","provider":"deepseek-official","model":"deepseek-v4-flash","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_configured_effort","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: provider \"deepseek-official\" model \"deepseek-v4-flash\" does not support reasoning effort \"unsupported\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl index 841bdb291d..d2d2f47853 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl @@ -1,5 +1,5 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json index 38f4eae1ad..62937be9b1 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index a03acfdecb..685168de6b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,5 +1,5 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json index 38f4eae1ad..62937be9b1 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/tool-schemas.1.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index b64494a028..d59385affa 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"72272791-eefd-48f8-94da-02b132ae9d2a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"f544ed7b-5a1f-4b6e-93b5-6af8342385fc"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Call subagent once. Ask that","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 3e3dfa357b..362fed6c4e 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"c54120cc-6a7f-41f6-a71d-42b4805fa2ca"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"5d344fef-f707-49ea-b804-ac384bf52700"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Attempt one subagent call beyond","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl index 57bbed4d87..36c2654033 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork-in-process/session.1.jsonl @@ -28,7 +28,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"257e572f-6f95-48f9-b3d7-4ea8b162f374"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl index b80c3d4936..2e6847126b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl @@ -1,5 +1,5 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json index 38f4eae1ad..62937be9b1 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/tool-schemas.1.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl index 4de198e6f6..def81f79a2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Truncated child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Truncated child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"885ea744-63dd-4198-95be-267b9db94a57"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index b64a6cfdfe..3dbae741e9 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"f216ca0e-6dcc-4ab3-9cdb-fe38d3dacca2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"507aa273-ce20-4aaa-9a35-abaae2a5b1cf"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index e7de6e1e7d..c623192004 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -28,10 +28,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} {"type":"step/start","data":{"turn":2,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"ac4f4d97-639d-4ad0-a513-219a58355531"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"cf2e06ce-6ea9-451a-bb75-46e59c7a78be"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index e1b186cd1f..dd6140142a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"47cdc6a0-a8c8-4842-964a-ad4bc97dc76a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"ed6eaae0-f071-44ea-9d95-d68185f87194"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index f8cb2e5924..4269894bfb 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -5,10 +5,10 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"f9132345-93c9-40c0-b489-5916bbca96bc"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"de519157-85ec-4e58-9d05-07b469aab403"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl index 14b644dc33..1d1f3f1372 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fadafbc9-263b-4169-82c6-a39868629377"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"e7e63c63-ff17-4f1b-a375-9aba4b477b44"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fadafbc9-263b-4169-82c6-a39868629377"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"1d6d2982-78f7-49b9-b32d-0eb465d672b1"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"e7e63c63-ff17-4f1b-a375-9aba4b477b44"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"46bdee11-0be5-4a62-a41d-08915b210451"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9ccb6b64-4dfb-47a2-9967-13ab05483998"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e195c568-4ea2-4a14-a27c-3ab43d8000b0"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl index 5f298c76c5..e708b437fe 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl @@ -2,19 +2,19 @@ {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} {"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"dc34a17f-fb30-4afe-a11f-a0d8a1d51658"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8c91be41-04b6-4c83-a3a6-95e323c807de"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"dc34a17f-fb30-4afe-a11f-a0d8a1d51658"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"12a26f3d-f11e-4de4-8bed-d997590d21e0"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8c91be41-04b6-4c83-a3a6-95e323c807de"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"591f5521-ef20-4f12-be4a-420489c9355b"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d368f9a5-7d0a-46f0-a7d8-10e1fafa1e74"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d38d405b-30c9-46b4-a165-78ae723f172e"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl index dd28fc243b..e5c3fdaf72 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl @@ -1,5 +1,5 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json index 38f4eae1ad..62937be9b1 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json +++ b/examples/acp-agent/tests/snapshots/subagent-report/tool-schemas.1.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -425,7 +442,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -437,6 +454,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl index efe72ff42b..a70c330e78 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"24630f5a-f790-469f-96a6-cf234ded3759"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index db3c652d58..0720890967 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json index b2236d44a3..630a9b086f 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/web-fetch/tool-schemas.expected.json @@ -260,6 +260,23 @@ } } }, + { + "name": "list_subagent_models", + "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", + "parameters": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Registered LLM provider id. Omit to list providers." + }, + "model": { + "type": "string", + "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." + } + } + } + }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -409,7 +426,7 @@ }, { "name": "subagent", - "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "description": "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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", "parameters": { "type": "object", "properties": { @@ -421,6 +438,18 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, + "provider": { + "type": "string", + "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." + }, + "model": { + "type": "string", + "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." + }, + "reasoning_effort": { + "type": "string", + "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." + }, "run_in_background": { "type": "boolean", "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index a42b3cf01d..fce39e713c 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -5,7 +5,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"12bbd4dd-4040-4cc7-8acf-e526144f1ee5"},"surfaceOp":"append"} diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index fa037ab935..759a252b4e 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -124,13 +124,17 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable maxDepth: 1 -# Fork stays one-shot because a continuable child's `report` tool and prompt -# section precede the inherited history a fork reuses; `run_in_background` is off -# as an explicit foreground-only choice even though agent-spine-demo mounts the -# generic Job runtime. See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. +# Fork omits model selection so provider/model stay equal to the parent and the +# inherited history remains eligible for KV Cache reuse. It stays one-shot because +# a continuable child's `report` tool and prompt section precede that history and +# invalidate the same prefix. `run_in_background` is off as an explicit +# foreground-only choice even though agent-spine-demo mounts the generic Job runtime. +# See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +# and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 1287de6339..6455b03e27 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,19 +1,19 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c66e310e-2597-4d01-85c8-2d70a9d831c0"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2324a504-7992-4dd5-b1a4-22783caf793c"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c66e310e-2597-4d01-85c8-2d70a9d831c0"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"fd0a0587-8df4-46b2-809a-317346a4c0f4"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2324a504-7992-4dd5-b1a4-22783caf793c"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"9b642d5e-ecc8-4c09-8f98-fd067b62ae63"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n job_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n job_output: {\n text: string;\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */\n list_subagent_models: {\n /** Registered LLM provider id. Omit to list providers. */\n provider?: string;\n /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */\n model?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */\n provider?: string;\n /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */\n model?: string;\n /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */\n reasoning_effort?: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n job_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n job_output: {\n text: string;\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n list_subagent_models: string;\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"02dd8a61-a39a-46d0-8f6f-457533271cae"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3f5bbe50-ec28-482a-ad44-729f575e12e6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 1f18e866a9..becebf92ee 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,19 +1,19 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"743dc5b8-8c32-49ae-b75d-71cc45562c1a"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"823e5037-9e96-4ef5-8c5b-cbe73b993ee2"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"743dc5b8-8c32-49ae-b75d-71cc45562c1a"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"e7caca87-00de-4018-a7cc-627620018806"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n job_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n job_output: {\n text: string;\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */\n list_subagent_models: {\n /** Registered LLM provider id. Omit to list providers. */\n provider?: string;\n /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */\n model?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */\n provider?: string;\n /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */\n model?: string;\n /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */\n reasoning_effort?: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n job_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n job_output: {\n text: string;\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n list_subagent_models: string;\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cd78f077-1fad-4cdc-ab56-09d39d9095cd"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c07ab6db-7108-46b4-ad18-3f81e9609546"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index c49d9b2745..4bed94ecda 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"e203969f-330f-4886-8d34-82d0001db89b"}]}} {"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":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"e203969f-330f-4886-8d34-82d0001db89b"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n job_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n job_output: {\n text: string;\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */\n list_subagent_models: {\n /** Registered LLM provider id. Omit to list providers. */\n provider?: string;\n /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */\n model?: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** 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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */\n provider?: string;\n /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */\n model?: string;\n /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */\n reasoning_effort?: string;\n /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n job_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n job_output: {\n text: string;\n job: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n list_subagent_models: string;\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e0351488-7bca-48d6-b7af-87d533858f47"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"749df5cd-1017-43c0-8c74-b2cab4752e59"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"9fd9ba62-1b6d-4bb7-98c6-97815e983026"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"57e5798f-59ce-43ee-90c2-dcf4a66de1f3"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,13 +22,13 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6f6c3cc3-350b-4afb-a3d1-8f91b9494628"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a3df67b0-ba4f-4fc3-a359-05a63f636ba9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"},"isError":false,"content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}]}} {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"}}} {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"},"isError":false,"content":[{"type":"text","text":"{\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n}"}]}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"40a17cbf-a853-4813-bbb5-7970cfbc7010"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"2e591904-ffdd-4b86-824e-a4a5667b9383"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -36,9 +36,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bdea090-4a9a-451f-bb21-459af50472fa"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"93019032-9a3e-4947-aaff-4790cc42ecbb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"c978c208-8ebd-4dd6-b997-606ca7de787e"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"3b86b42c-4989-4490-a1f4-471dc8984c00"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -46,13 +46,13 @@ {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e55b2b2e-497c-45d2-8115-16f317ae573f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"df7d2480-5bf2-4780-ab43-376d3f867b2a"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} {"type":"tool-workflow/run-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","name":"advanced-headless-snapshot"}} {"type":"tool-workflow/agent-start","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} {"type":"tool-workflow/agent-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"outcome":"completed"}} {"type":"tool-workflow/run-end","data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","stopReason":"completed"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"f85f58fc-8d7e-4c9f-a0fe-caff480a9fec"}},"sourceEventSeqs":[48],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"9fd8c742-bba7-4a6a-9c91-ac5d19a77eff"}},"sourceEventSeqs":[48],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -60,9 +60,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"68bd993f-a0bd-4ea8-ad16-6b1a19e09bd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ad845a3d-0053-4991-bccc-ba09bd640699"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"2d90e3b5-2a4c-4408-a1a0-3d009786a07b"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"d21035a4-fcb1-46b5-af03-e9f9b7685444"}},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -70,6 +70,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f3823367-2e25-43d2-a129-70dc492b2a90"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"32f4ee19-e038-4c1c-9fc9-4288be6ca08f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl index 575bbc5dfa..af15e860e3 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -1,32 +1,32 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"10eb2388-2d40-4564-af27-e7a5419fc14e"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"b187bcb5-4465-48d2-8aa1-ad12060ca048"}]}} {"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":"user/message","data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"10eb2388-2d40-4564-af27-e7a5419fc14e"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"b187bcb5-4465-48d2-8aa1-ad12060ca048"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a71b2cfd-c18f-4a1b-82f6-e89fb371a87e"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6d534ce-c24d-49ee-ac3c-d05257fa03fb"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"c9e68608-2dff-44bc-a344-b01006272378"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"86b7cf93-1014-4334-a4af-5862c82fd5fb"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} {"type":"compaction/start","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"compaction/summary","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":266,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"3668b957-07a2-4cb7-96b1-98a23ac8cdb8"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"a4847ff0-6fa0-419b-8427-eade600c05ee"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} {"type":"compaction/end","data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bcbfd4ff-60e5-4634-ae39-4de3708a8abc"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"18a03939-b8df-491e-ac8a-a74938f2aa4c"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 6e5148b4de..a3b7e4dc17 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,21 +1,21 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"41d289fe-fc8a-464a-b3e1-db2c0189cae9"}]}} {"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":"user/message","data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}]},"role":"user","id":"053af702-9950-4860-913a-3c7e45a54f9d"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"41d289fe-fc8a-464a-b3e1-db2c0189cae9"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}]},"role":"user","id":"21b5e1f2-ef74-44cf-820b-d2a0bf77a519"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a job id for job_output/job_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a job id immediately; collect with job_output or stop with job_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["job_id"]}},{"name":"list_subagent_models","description":"Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.","parameters":{"type":"object","properties":{"provider":{"type":"string","description":"Registered LLM provider id. Omit to list providers."},"model":{"type":"string","description":"Exact model id to inspect. Requires provider; omit to list that provider's advertised models."}}}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"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. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"provider":{"type":"string","description":"LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route."},"model":{"type":"string","description":"Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route."},"reasoning_effort":{"type":"string","description":"Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default."},"run_in_background":{"type":"boolean","description":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a job id for job_output/job_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a job id immediately; collect with job_output or stop with job_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"911213f8-acce-47be-a4f2-9d72ef55d83a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"36ae38c0-78e0-4302-9457-dd88fd8bde7c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"2da21b47-7fb3-444c-99a6-2c21743731ee"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"f6583921-7e08-49f6-b5bd-99f6a3ced4b2"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cf12d7ee-322a-4057-97b0-98d828a96f1a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"000f1cda-e29a-42e1-9c7c-6e3803aa9316"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"d1ffb2dc-6a33-4c9e-aeb9-b87bf6da8617"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"68ac57a2-530b-4479-b278-78b8c10657d5"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9b99ae9-4685-4ee5-b951-fbe58f84c4e3"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c67744f1-e874-4678-8ea4-45eb96d95692"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"d0d7331d-0d54-457a-9f7b-beb22abd34e6"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"e77a5157-0844-48d2-9a4d-4b242c5d8357"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":3}} {"type":"step/start","data":{"turn":1,"step":4}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9bb8ec3d-6e6f-44a2-8957-8f2d855f4834"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cc545337-43b3-4fc6-8a17-74da2a49086a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"16fc1ca2-0eca-42d7-85d0-31794424c260"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"3cde95e4-fcc3-422d-ae60-cc041c2cb223"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":4}} {"type":"step/start","data":{"turn":1,"step":5}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4ebdc957-0369-4bbb-a5a5-4d2ef8ac3493"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"830096ca-82a9-4f44-b798-7d7040c35172"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"55a3e2cc-dbc5-44bf-a824-e3bbc8568cd5"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"f43c7146-3cb9-4e91-96f8-aa77b26d09e2"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6f956d23-5437-4a75-93a9-3abacd378e07"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3070bc05-8897-4f63-8c9d-257e6b518b67"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"be12d914-6fe1-4c1e-8262-65b2aa9c20e5"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"bc65b492-2cef-4fe6-8476-910bdc21d4ed"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":6}} {"type":"step/start","data":{"turn":1,"step":7}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9cca9680-5795-47d8-8edc-f6d44bcaa1ef"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ce300b5f-bfbf-4d85-8801-dce12f260d36"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":7}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl b/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl index f07a82ecbc..d2ac2d31c6 100644 --- a/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl @@ -1,5 +1,5 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","origin":"subagent","delegationDepth":1} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","data":{}} {"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 report."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index 013d23d9ee..286ae33dcc 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -3,12 +3,12 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"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":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"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 DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"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":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl index 9efed81901..332cf1be07 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl @@ -2,6 +2,7 @@ {"type":"turn/start","data":{"turn":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"sandbox/mode","data":{"mode":"read-only"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"}},"reason":"initial"}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","data":{}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} @@ -11,16 +12,16 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"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 DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -28,6 +29,6 @@ {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index 8ca9855ed4..7b68ab4481 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { describe, expect, it } from 'vitest' @@ -26,7 +26,7 @@ const sessionId = SessionId('subagent-inheritance-parent') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const task = 'Delegate the write probe to a subagent.' -/** Seed a completed parent turn with the only read-only fact in the app. */ +/** Seed a completed parent turn with its read-only policy and current LLM selection. */ async function seedReadOnlyParent(root: string, cwd: string): Promise { const ctx = new Context() await ctx.plugin(SessionStore) @@ -42,7 +42,22 @@ async function seedReadOnlyParent(root: string, cwd: string): Promise { { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, + { + type: 'request/header', + seq: 3, + time: 13, + data: { + header: { + config: { + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('low'), + }, + }, + reason: 'initial', + }, + }, + { type: 'turn/end', seq: 4, time: 14, data: { turn: 1, reason: { kind: 'completed' } } }, ] try { await ctx.sessionPersistence.create(meta) diff --git a/examples/python-sdk-agent/cordis.yml b/examples/python-sdk-agent/cordis.yml index 6a04f5f42d..eeececff10 100644 --- a/examples/python-sdk-agent/cordis.yml +++ b/examples/python-sdk-agent/cordis.yml @@ -69,6 +69,7 @@ config: provider: spawn toolName: subagent + enableModelSelection: true enableRunInBackground: false - id: tool-todo diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 625fa2f16f..230ada0336 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -144,7 +144,9 @@ describe('Python SDK dsh profile keyless smoke', () => { }, }, }) + const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.max_tokens).toBe(1234) + expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models') child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) diff --git a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl index f00b85e15f..48d98ad6de 100644 --- a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl +++ b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl @@ -104,7 +104,7 @@ {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":3,"time":0,"data":{"turn":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":5,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":5,"time":0,"data":{"version":3,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"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"}}} diff --git a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl index 52aa539f93..059bded5dc 100644 --- a/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl +++ b/examples/python-sdk-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"echo probe"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\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\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"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":"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":"dc291267-28a7-40f4-adac-cd856dbe0bba"},"surfaceOp":"append"} diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index da2d77ece5..aa51d159f6 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -15,6 +15,7 @@ import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controlle import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' @@ -448,7 +449,11 @@ describe('subagent ownership fence', () => { type: 'subagent/descriptor', seq: 2, time: 3, - data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, + data: snapshotSubagentDescriptor({ + mode: 'continuable', + provider: 'spawn', + label: 'child', + }), }, { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, ] as SessionEvent[] diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 65da35f554..981e791fb4 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -327,12 +327,15 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable - # Fork stays one-shot: a continuable child's `report` tool and prompt - # section precede the inherited history a fork exists to reuse; one-shot - # fork children install neither, keeping the parent's request prefix. - # See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. It stays one-shot + # because a continuable child's `report` tool and prompt section precede that + # history and invalidate the same prefix. + # See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md + # and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index f22884cc77..18a0d3911a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -41,6 +41,11 @@ # `dsh.client` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: + # Host-owned opt-in sampled when a new Web session receives its preset + # delegation tools. The Models page edits this settings namespace. + - id: subagent-model-selection-settings + name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' + - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker-thread' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 2f26a9daed..68c43da265 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 0eb68f97f2..add13d5562 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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/client/ui-settings-models/README.md -README.md: cc430c91b9c4124fc70d0ec205b5870012dd5554 -README.zh.md: 62358adbc6f055697efe33e29e579f0aec64efc3 +README.md: 0daa9c5288f169138a5961e8fd6bee7818dba89b +README.zh.md: b2f80b845302d29728a0574b82f565c96acf59f2 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index cc430c91b9..0daa9c5288 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Models settings and product-onboarding plugin. The same client Cordis plugin registers the Models page plus two ordered first-run dialogs: a versioned internal-testing notice and the conditional official-DeepSeek credential step. Both steps share one modal wrapper and remain sequenced by `settings.onboarding`. The Models plane joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. +When the Host advertises the `subagent-model-selection` settings namespace, Models also renders a localized switch above the provider rows. It defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples it while composing a new top-level Session; changing it does not reconfigure running Sessions, while child Sessions inherit their parent's recorded decision. + Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`. On loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API; only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 62358adbc6..b2f80b8453 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -4,6 +4,8 @@ 模型设置与产品引导插件。同一个 client Cordis 插件会注册 Models 页面和两个有序的首次使用弹窗:版本化内测声明,以及按条件显示的 DeepSeek 官方凭据步骤。两个步骤共用同一套弹窗组件,并继续由 `settings.onboarding` 排序。Models 平面把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret slot)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 +Host 公布 `subagent-model-selection` settings namespace 时,Models 还会在提供方行上方渲染本地化开关。它默认关闭,只通过 `settings.update` 携带 namespace revision 写入 `{ enabled }`。Host 会在组合新的顶层 Session 时读取它;修改设置不会重新配置运行中的 Session,而子 Session 会继承父级已记录的决定。 + 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 声明步骤在 `src/client/locales.ts` 中持有完整文案,并在 `src/onboarding-copy.ts` 中持有确认版本。回环访问会通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`;只有明确点击「继续」才会记录当前版本。非回环浏览器无法使用这项仅限 Host 的 namespace,因此确认仅在当前进程有效,重载后声明会再次出现。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 3719535a3a..1c767386dd 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -40,6 +40,87 @@ color: var(--dsw-alias-state-success-primary); } +.preferenceCard { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 16px; + margin-top: 4px; + padding: 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; +} + +.preferenceCopy { + min-width: 0; +} + +.preferenceTitle { + margin: 0; + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.preferenceDescription { + margin: 2px 0 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.switch { + box-sizing: border-box; + position: relative; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switchOn { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.switch:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); +} + +.switchThumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switchOn .switchThumb { + transform: translateX(16px); +} + +.preferenceStatus, +.preferenceCard > .error { + grid-column: 1 / -1; +} + +.preferenceStatus { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-success-primary); +} + .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ @@ -623,7 +704,8 @@ select.input { } @media (prefers-reduced-motion: reduce) { - .customizedSummary::before { + .customizedSummary::before, + .switchThumb { transition: none; } } diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 7f178564d6..9501185e87 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -22,6 +22,7 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor import type { ModelsSettingsStore, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' +import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -278,12 +279,24 @@ function Loaded({ injected }: { injected: ModelsSectionFace }): ReactNode { // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema) + const subagentModelSelection = state.namespaces.get('subagent-model-selection') return (

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} + {subagentModelSelection === undefined + ? null + : ( + + )} {savedIdentity === undefined ? null : ( diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx new file mode 100644 index 0000000000..3b62f28e60 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx @@ -0,0 +1,87 @@ +/** User control for model-selectable subagent delegation in new sessions. */ + +import { useState } from 'react' +import type { ReactNode } from 'react' +import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { ModelsSettingsStore } from './store.ts' +import type { en } from './locales.ts' +import { messageOf } from './store.ts' +import styles from './ModelsSection.module.css' + +/** Props for the Host-owned subagent model-selection preference. */ +export interface SubagentModelSelectionCardProps { + /** Current redacted namespace view. */ + namespace: SettingsNamespaceView + /** Whether the settings provider accepts writes. */ + writable: boolean + /** Settings wire face. */ + api: Pick + /** Models page controller to refresh after a commit. */ + controller: ModelsSettingsStore + /** Localized Models copy. */ + t: (key: keyof typeof en) => string +} + +/** Read the schema-validated resolved boolean from a namespace view. */ +function enabledOf(namespace: SettingsNamespaceView): boolean { + if (typeof namespace.value !== 'object' || namespace.value === null) return false + return (namespace.value as { enabled?: unknown }).enabled === true +} + +/** Render and persist the default-off new-session preference. */ +export function SubagentModelSelectionCard({ + namespace, + writable, + api, + controller, + t, +}: SubagentModelSelectionCardProps): ReactNode { + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(undefined) + const enabled = enabledOf(namespace) + + const toggle = (): void => { + setSaving(true) + setSaved(false) + setError(undefined) + void api.settings.update({ + ns: namespace.ns, + patch: { enabled: !enabled }, + expectedRevision: namespace.revision, + }).then(async (response) => { + if (!response.result.ok) throw new Error(response.result.error.message) + controller.acceptNamespace(response.result.value) + await controller.load() + setSaved(true) + }).catch((reason: unknown) => { + setError(messageOf(reason)) + }).finally(() => { setSaving(false) }) + } + + return ( +
+
+

+ {t('subagentModelSelectionTitle')} +

+

{t('subagentModelSelectionDescription')}

+
+ + {saved + ?

{t('subagentModelSelectionSaved')}

+ : null} + {error === undefined ? null :

{error}

} +
+ ) +} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index f1b0718ba5..176e33fe5e 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -5,6 +5,10 @@ export const en = { nav: 'Models', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', + subagentModelSelectionTitle: 'Subagent model selection', + subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', + subagentModelSelectionToggle: 'Allow subagents to choose models', + subagentModelSelectionSaved: 'Saved. New sessions use this setting.', edit: 'Edit', editProvider: 'Edit {provider}', remove: 'Delete', @@ -109,6 +113,10 @@ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', + subagentModelSelectionTitle: 'Subagent 自选模型', + subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', + subagentModelSelectionToggle: '允许 subagent 自选模型', + subagentModelSelectionSaved: '已保存,新会话将使用此设置。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 349798acdd..2b17c9fc4b 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -124,6 +124,15 @@ export class ModelsSettingsStore { private readonly describeFace: SettingsDescribeFace, ) {} + /** + * Fold one successful settings write into the shared mirror before rejoining + * this page's rows. + * @param view - namespace view returned by the settings wire method. + */ + acceptNamespace(view: SettingsNamespaceView): void { + this.describeFace.acceptView(view) + } + /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 8a3b8fce0a..5d0cbe8fed 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -8,6 +8,7 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-re import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' +import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -122,6 +123,14 @@ function wireNamespaces(): SettingsNamespaceView[] { secrets: [], revision: 0, }, + { + ns: 'subagent-model-selection', + schema: JSON.parse(JSON.stringify(Schema.object({ enabled: Schema.boolean().default(false) }).toJSON())) as unknown, + value: { enabled: false }, + applies: 'live', + secrets: [], + revision: 4, + }, ] } @@ -143,9 +152,10 @@ function scriptedFace(overrides: { set?: ReturnType unset?: ReturnType } = {}) { - const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) - const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) - const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) + const providerNamespace = wireNamespaces().find(view => view.ns === 'llm-pi-ai')! + const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) + const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) + const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(providerNamespace))) const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({}))) const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({}))) const face = { @@ -236,6 +246,71 @@ describe('ModelsSection', () => { expect(document.body.textContent).toBe('') }) + it('persists the default-off subagent model-selection switch for new sessions', async () => { + const enabledNamespace: SettingsNamespaceView = { + ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, + value: { enabled: true }, + user: { enabled: true }, + revision: 5, + } + const update = vi.fn(() => Promise.resolve(ok(enabledNamespace))) + await mountSection({ update }) + + const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(toggle.getAttribute('aria-checked')).toBe('false') + fireEvent.click(toggle) + + await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) + expect(update).toHaveBeenCalledWith({ + ns: 'subagent-model-selection', + patch: { enabled: true }, + expectedRevision: 4, + }) + expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + }) + + it('reports rejected subagent model-selection updates and permits a retry', async () => { + const update = vi.fn() + .mockResolvedValueOnce(fail('revision changed')) + .mockResolvedValueOnce(ok({ + ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, + value: { enabled: true }, + revision: 5, + })) + await mountSection({ update }) + + const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + fireEvent.click(toggle) + expect((await screen.findByRole('alert')).textContent).toBe('revision changed') + + fireEvent.click(toggle) + await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('keeps malformed and read-only subagent preferences off', () => { + const namespace = { + ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, + value: null, + } as unknown as SettingsNamespaceView + const update = vi.fn() + render( + , + ) + + const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(toggle.getAttribute('aria-checked')).toBe('false') + expect((toggle as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(toggle) + expect(update).not.toHaveBeenCalled() + }) + it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { await mountFirstRun() // Nothing is reachable yet, and DeepSeek has no configured credential and diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 2a5445de6b..88d4e6da7d 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/agent-loop/README.md -README.md: 907bbe75fd687388be044916f4c4509e1c552177 -README.zh.md: 76a73155a3d9f811931cf0f86c227e6ab3dc1c32 +README.md: 1b233ae1203171930ef5b58de93ec67381ec4918 +README.zh.md: 81af654072f23c5280e2e14bc891972b5e1f37d5 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 907bbe75fd..1b233ae120 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -42,6 +42,7 @@ interface Config { id: string // required provider?: string model?: string + reasoningEffort?: string // non-empty initial reasoning effort maxTokens?: number // positive per-request output-token cap resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session @@ -49,7 +50,7 @@ interface Config { } ``` -Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`; it is also the whole of the `agent-loop` Settings section, so a user layer over this entry caps the next tool group without a restart, and a value that is not a positive integer is refused at the write rather than at that group. `agents` is deliberately absent from that section — it is consumed once when the service starts, so a stored change could only look like it had an effect. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. +Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional non-empty `reasoningEffort` seeds the request's reasoning setting; `agent/request` may override it, and adapter resolution validates the effective value recorded in the request header. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`; it is also the whole of the `agent-loop` Settings section, so a user layer over this entry caps the next tool group without a restart, and a value that is not a positive integer is refused at the write rather than at that group. `agents` is deliberately absent from that section — it is consumed once when the service starts, so a stored change could only look like it had an effect. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. ### Internal concrete driver diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 76a73155a3..81af654072 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -42,6 +42,7 @@ interface Config { id: string // required provider?: string model?: string + reasoningEffort?: string // non-empty initial reasoning effort maxTokens?: number // positive per-request output-token cap resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session @@ -49,7 +50,7 @@ interface Config { } ``` -通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`;它同时也是 `agent-loop` Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用,而非正整数的值会在写入时被拒绝,而不是到那一组时才失败。`agents` 刻意不在该段中——它在服务启动时被消费一次,所以存储的改动只会看起来生效。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 +通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的非空 `reasoningEffort` 会提供请求的初始推理强度;`agent/request` 可以覆盖它,适配器解析会校验记录在请求 header 中的最终值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`;它同时也是 `agent-loop` Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用,而非正整数的值会在写入时被拒绝,而不是到那一组时才失败。`agents` 刻意不在该段中——它在服务启动时被消费一次,所以存储的改动只会看起来生效。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 ### 包内部具体驱动器 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 3ef1ec7aa4..6bf7517903 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -438,11 +438,12 @@ export class ReactLoopAgent implements Agent { const persistedHeader = session.requestHeader() const persistedConfig = persistedHeader?.config const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' } - const reasoningEffort = persistedConfig?.provider === route.provider + const persistedReasoningEffort = persistedConfig?.provider === route.provider && persistedConfig.model === route.model && persistedHeader?.adapterDefaults?.reasoningEffort !== true ? persistedConfig.reasoningEffort : undefined + const reasoningEffort = this.options.reasoningEffort ?? persistedReasoningEffort const maxTokens = this.options.maxTokens const seedConfig = deepFreeze(structuredClone( this.requestHeaderLogged diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 371154a7c9..38ee7dfef5 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -19,7 +19,7 @@ import type { ResumeAgentOptions, SessionStartSource, } from '@deepseek-ai/dsh-agent' -import { errorChain } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' @@ -304,6 +304,7 @@ export class AgentLoop extends Service implements AgentFactory { sessionId: z.string().min(1), provider: z.string(), model: z.string(), + reasoningEffort: z.string().min(1) as z>, maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER), cwd: z.string(), resumeSessionId: z.string(), diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 2105b86f42..4082b43452 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, CallId, LlmError, ReasoningEffortId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' @@ -77,6 +77,34 @@ describe('agent loop', () => { expect(adapter.requests[0]?.maxTokens).toBe(256) }) + it('seeds an AgentOptions reasoning effort into the first model request', async () => { + const effort = ReasoningEffortId('high') + const adapter = new MockAdapter([textResponse('reasoned')], { + efforts: [{ id: effort, name: 'High' }], + defaultEffort: effort, + }) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create( + SessionId('configured-reasoning-effort'), + { provider: 'mock', model: 'mock', reasoningEffort: effort }, + ) + + send(agent, 'use the configured reasoning effort') + await waitForIdle(ctx, agent) + + expect(adapter.requests[0]?.reasoningEffort).toBe(effort) + }) + + it('validates reasoning effort in declarative agent config', () => { + const effort = ReasoningEffortId('high') + expect(AgentLoop.Config({ + agents: [{ id: 'configured-agent', reasoningEffort: effort }], + }).agents[0]?.reasoningEffort).toBe(effort) + expect(() => AgentLoop.Config({ + agents: [{ id: 'configured-agent', reasoningEffort: ReasoningEffortId('') }], + })).toThrow() + }) + it('cancels queued wakeup work together with an active maintenance task', async () => { const adapter = new MockAdapter([textResponse('park reply')]) const ctx = await harness(adapter) @@ -1429,7 +1457,11 @@ describe('agent loop', () => { }) it('creates agents from config on startup', async () => { - const adapter = new MockAdapter([textResponse('from config')]) + const effort = ReasoningEffortId('high') + const adapter = new MockAdapter([textResponse('from config')], { + efforts: [{ id: effort, name: 'High' }], + defaultEffort: effort, + }) const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) @@ -1437,7 +1469,7 @@ describe('agent loop', () => { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }], + agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', reasoningEffort: effort }], }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1451,6 +1483,9 @@ describe('agent loop', () => { send(agent, 'hi') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) + expect(adapter.requests[0]?.reasoningEffort).toBe(effort) + const header = agent.session.events.find(event => event.type === 'request/header') + expect(header?.type === 'request/header' && header.data.header.config.reasoningEffort).toBe(effort) }) it('attaches config agent cwd to the fresh session header', async () => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 4dd5627b2e..a9d78f3fad 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/agent/README.md -README.md: f6b698e93b254c97786155e7d2c7e81f07c0d981 -README.zh.md: 5e3da8a5d09d06fa4fc9726406aae9c6d5e69c3e +README.md: 70b396d787de5d95332c379ff20ab92c64065857 +README.zh.md: fee72f3cd1fb456ae639d6444fe3fe914c41220a diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f6b698e93b..70b396d787 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -14,7 +14,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installModelSelection(agentCtx, selection)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies its provider and model to prompt variables, and applies the complete selection to request routing for one step; an absent selected effort clears an inherited effort so adapter/provider defaults apply. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. -`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. +`AgentOptions` supplies the initial provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The concrete loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 5e3da8a5d0..fee72f3cd1 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -14,7 +14,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 -`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。具体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 +`AgentOptions` 提供初始的提供方/模型路由、可选且由适配器定义的 `reasoningEffort`,以及可选的正数 `maxTokens` 输出上限。具体循环会校验确切模型支持的推理强度、解析适配器默认值,把生效值记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 - `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber dispose。 - 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。 diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index df7449d406..3f8f7c512b 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -7,7 +7,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig, LlmFailure, ReasoningEffortId, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' import type { Inbox } from './inbox.ts' @@ -27,6 +27,8 @@ export interface AgentOptions { provider?: string /** Model id interpreted by the selected provider adapter. */ model?: string + /** Adapter-owned reasoning effort for the selected provider/model route. */ + reasoningEffort?: ReasoningEffortId /** Maximum output tokens for each conversation-model request. */ maxTokens?: number } diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index b4e7117541..c005211567 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -49,6 +49,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/end', 'step/start', 'subagent/descriptor', + 'subagent/model-selection-enabled', 'team/member', 'team/message/delivered', 'team/message/queued', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index ce2007c34b..38d68deae6 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -30,7 +30,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', - 'list_agents', 'list_agents', 'lsp', 'pwsh', 'pwsh', 'ralph', + 'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', @@ -88,7 +88,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { // agents surface this one package as both `subagent` and `subagent_fork`. const catalog = await collectToolCatalog() const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent') - expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent']) + expect(subagent?.schemas.map(s => s.name)).toEqual(['list_subagent_models', 'subagent']) expect(subagent?.note).toMatch(/subagent_fork/) }) }) diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl index 291a18ba1e..7fd433c32a 100644 --- a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-architecture-review/session.jsonl @@ -171,7 +171,7 @@ {"type":"session/end-seed","data":{},"seq":169,"time":1787472100000} {"type":"turn/start","data":{"turn":29},"seq":170,"time":1787472100001} {"type":"user/message","data":{"id":"preview-review-user","role":"user","content":[{"type":"text","text":"Review whether the preview fixture is isolated from future WebFS data."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":171,"time":1787472100002} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"fork","label":"Review preview architecture"},"seq":172,"time":1787472100003} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"fork","label":"Review preview architecture"},"seq":172,"time":1787472100003} {"type":"step/start","data":{"turn":29,"step":1},"seq":173,"time":1787472100004} {"type":"assistant/message","data":{"turn":29,"step":1,"message":{"id":"preview-review-assistant","role":"assistant","content":[{"type":"text","text":"The bundled fixture is static image content; future WebFS state remains user-owned."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":174,"time":1787472100005} {"type":"step/end","data":{"turn":29,"step":1},"seq":175,"time":1787472100006} diff --git a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl index 6353e854c4..03b884adc6 100644 --- a/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl +++ b/packages/experimental/webworker-runtime/tests/fixtures/vfs-example/home/sessions/--dsh-workspace--/preview-follow-up-builder/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"preview-follow-up-builder","createdAt":1787472200000,"cwd":"/dsh/workspace","parentSession":"preview-showcase","origin":"subagent","delegationDepth":1,"agentPreset":"standard"} {"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472200000} {"type":"user/message","data":{"id":"preview-builder-user","role":"user","content":[{"type":"text","text":"Check that the Preview workspace can support follow-up tasks."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472200001} -{"type":"subagent/descriptor","data":{"version":2,"mode":"continuable","provider":"spawn","label":"Continue preview verification"},"seq":2,"time":1787472200002} +{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Continue preview verification"},"seq":2,"time":1787472200002} {"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472200003} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-builder-assistant","role":"assistant","content":[{"type":"text","text":"This child is continuable and ready for another verification turn."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}}},"sourceEventSeqs":[],"surfaceOp":"append","seq":4,"time":1787472200004} {"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472200005} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eea3a05e2b..320c44b9aa 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -195,7 +195,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async recompose(agentCtx: Context, id: string): Promise', - description: 'Re-link one agent to a different preset\'s standing composition.\n\nOnly valid while the agent has produced nothing: swapping tools mid conversation would leave logged tool calls the new composition cannot make. The CALLER owns that check — this method does not read session history.\n\nThe swap is a parent re-link, not an unmount: standing mounts are shared and permanent, so the old composition stays for its other agents and the new one is ensured BEFORE the link moves. An unknown or unusable preset therefore throws with the agent exactly as it was — there is no torn-down state to restore. The re-link runs through the binding this roster kept from the agent\'s mount — dsh-scope\'s only re-link authority. An agent that never composed one has nothing to re-link: the switch is then the agent\'s first bind, exactly a mount.', + description: 'Re-link one agent to a different preset\'s standing composition.\n\nOnly valid while the agent has produced nothing: swapping tools mid conversation would leave logged tool calls the new composition cannot make. The CALLER owns that check — this method does not read session history.\n\nThe swap is a parent re-link, not an unmount: standing mounts are shared and permanent, so the old composition stays for its other agents and the new one is ensured BEFORE the link moves. An unknown or unusable preset therefore throws with the agent exactly as it was — there is no torn-down state to restore. The re-link runs through the binding this roster kept from the agent\'s mount — dsh-scope\'s only re-link authority. An agent that never composed one has nothing to re-link: the switch is then the agent\'s first bind, exactly a mount. A committed re-link emits `tools/change` because changing the parent scope changes the Agent\'s resolved tool set without adding or removing registry entries.', parameters: [{ name: 'agentCtx', description: 'the agent\'s scope context.' }, { name: 'id', description: 'the preset to compose the agent from instead.' }], returns: 'the preset now installed.', throws: ['when the preset is unknown or its composition is unusable.'], @@ -1906,6 +1906,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'subagentModelSelection', + summary: 'Singleton settings owner read by delegation tools when an Agent is published.', + description: 'Singleton settings owner read by delegation tools when an Agent is published.', + methods: [ + { + signature: 'currentEnabled(): boolean', + description: 'Read the preference for the next eligible Agent publication.', + parameters: [], + returns: 'whether that Agent should receive model-selectable delegation.', + }, + ], + }, { key: 'subagents', summary: 'Named provider registry with one-shot runs, durable discovery, and continuable-child operations.', @@ -3124,7 +3137,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentOptions', - declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', + declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n reasoningEffort?: ReasoningEffortId;\n maxTokens?: number;\n}', }, { name: 'AgentPreset', @@ -3416,7 +3429,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContinuableSubagentDescriptorData', - declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', + declaration: 'export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'continuable\';\n readonly label: string;\n readonly agentProvider?: string;\n readonly agentModel?: string;\n readonly agentReasoningEffort?: ReasoningEffortId;\n readonly persona?: string;\n readonly toolFilter?: ToolRestriction;\n}', }, { name: 'CordisDynamicPackageId', @@ -4928,7 +4941,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCapabilities', - declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', + declaration: 'export interface SubagentCapabilities {\n readonly agentOptions: boolean;\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}', }, { name: 'SubagentDescendantListEntry', diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 2d2b710299..57ecda0a84 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 0570ead15e0c408e838cab7a641293ccdb11a702 -README.zh.md: 7f58ba2b6a9be8f03fdcc4538777889780949057 +README.md: 7433bb75104506ec2409c659f3d30058abc6f9a4 +README.zh.md: 7dcdfeac17b0bfca70a293760061182292edb531 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0570ead15e..7433bb7510 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -50,7 +50,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash`, `deepseek-v4-pro`, and the image-capable `deepseek-v4-flash-vision-exp`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as the fast, economical choice for focused work, `deepseek-v4-pro` as the stronger, higher-cost choice for complex or quality-critical work, and the image-capable `deepseek-v4-flash-vision-exp`; each has a 1,000,000-token context window. An explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors, the Web selector, and model discovery tools, but remain advisory: unlisted model ids still pass through unchanged as text-only routes. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. An image-capable catalog entry declares `inputModalities: [text, image]` and may set `imagePixelBudget` to an exact positive integer or `low`; omission uses 640,000 total pixels, while `low` selects 512 by 512 total pixels. `imageMaxBytes` defaults to 1MiB. The attachment store scales by `min(1, sqrt(pixelBudget / (width * height)))` and rounds inward to keep the pixel count at or below the hard cap, so a 2048 by 1024 normalized attachment becomes about 1130 by 565 instead of a forced square. Request encoders run lazily: alpha images try WebP (effort 0) at 85, 75, then 60, and opaque images try JPEG at those qualities; when every quality exceeds 1MiB the smallest output is used. Concurrent generation of one `variantId` shares one transform. A caller can cancel its own wait without interrupting other waiters; the transform stops when no waiter remains. The adapter normally uploads the exact derived request bytes through `POST /files` and sends `{type: "file", file_id}` blocks. A failed or timed-out file-id resolution rebuilds the whole chat request with those same request versions as base64 data URLs; one request never mixes file ids and inline images. Every retained image is preceded by text naming the complete attachment id and actual request dimensions. When the attachment provider exposes a host object and the current filesystem maps it into the tool execution world, the text also includes that read-only path and the matching extension for a writable copy. This access is resolved independently from the deterministic request version and its `variantId`. The descriptor states that the preview and normalized image may differ from the upload. User, tool-result, agent-loop, compaction, and direct `ctx.llm.stream` requests all use this projection. Text-only routes receive stable attachment placeholders while durable history keeps its image references. @@ -66,7 +66,7 @@ Concurrent resolution of one scoped `variantId` shares one Files upload with wai `maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. A catalog entry may carry its own `maxTokens`, which wins for that model; an entry without one, and any unlisted pass-through id, resolve to the profile value, so adding a per-model cap changes one model rather than the route. Exact-model resolution exposes the winner as `defaultMaxTokens`; `LlmRuntime` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`. -The same exact-model result exposes ordered `off`, `low`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `low`, `high`, and `max` enable thinking and serialize as the same official top-level `reasoning_effort` value; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. +The same exact-model result exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the advertised default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `low`, `high`, and `max` enable thinking and serialize as the same official top-level `reasoning_effort` value; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `low`, `high`, or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 7f58ba2b6a..7dcdfeac17 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -50,7 +50,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`、`deepseek-v4-pro` 与支持图片输入的 `deepseek-v4-flash-vision-exp`,三者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布适合聚焦任务、快速且经济的 `deepseek-v4-flash`,适合复杂或质量关键任务、更强但成本更高的 `deepseek-v4-pro`,以及支持图片输入的 `deepseek-v4-flash-vision-exp`;三者的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器、Web 选择器和模型发现工具等客户端,但仍只提供建议:未列出模型 id 仍原样传递,并按纯文本路由处理。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 支持图片的 catalog 配置项声明 `inputModalities: [text, image]`,并可把 `imagePixelBudget` 设为确切正整数或 `low`;省略时使用总像素 640,000,`low` 选择总像素 512×512。`imageMaxBytes` 默认值为 1MiB。附件存储按 `min(1, sqrt(pixelBudget / (width * height)))` 缩放,并向预算内取整,确保总像素不超过硬上限。因此 2048×1024 规范化附件会得到约 1130×565 的请求版本,而不会被强制变成正方形。请求编码按需执行:透明图片依次尝试质量 85、75、60 的 WebP(effort 0);非透明图片依次尝试这些质量的 JPEG。全部质量档都超过 1MiB 时使用其中最小的产物。同一 `variantId` 的并发生成共享一次变换。调用方可以单独取消等待,不会中断其他等待方;没有等待方时才会停止变换。适配器通常通过 `POST /files` 上传确切的派生请求字节,再发送 `{type: "file", file_id}` 块。File ID 解析失败或超时后,适配器会用相同请求版本的 base64 data URL 重新组装整个 chat 请求;同一请求不会混用 file ID 和内联图片。每张保留图片前都有文本,写明完整附件 ID 和实际请求尺寸。附件提供方给出宿主对象且当前文件系统能够将其映射到工具执行环境时,文本还会给出该只读路径,并指出复制到可写路径时应使用的匹配扩展名。该访问方式独立于确定性的请求版本及其 `variantId`。描述也会说明预览和规范化图片可能与上传图片不同。User、工具结果、agent loop、压缩和直接 `ctx.llm.stream` 请求都使用该投影。纯文本路由会收到稳定的附件占位文本,持久历史继续保留图片引用。 @@ -66,7 +66,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。Catalog 配置项可以自带 `maxTokens`,它对该模型胜出;不含该上限的配置项以及任何未列出原样传递 id 都解析为 profile 值,因此新增按模型的上限只改变一个模型,而非整条路由。确切模型解析会将胜出值公开为 `defaultMaxTokens`;`LlmRuntime` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。 -同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`low`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`low`、`high` 和 `max` 会启用思考,并以同名值序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序且附带选择指引的 `off`、`low`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择公布的默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`low`、`high` 和 `max` 会启用思考,并以同名值序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `low`、`high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 92761bb840..35212d32d6 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -173,13 +173,33 @@ const LOW_REASONING_EFFORT = ReasoningEffortId('low') const HIGH_REASONING_EFFORT = ReasoningEffortId('high') const MAX_REASONING_EFFORT = ReasoningEffortId('max') const REASONING_EFFORTS = [ - { id: OFF_REASONING_EFFORT, name: 'Off' }, - { id: LOW_REASONING_EFFORT, name: 'Low' }, - { id: HIGH_REASONING_EFFORT, name: 'High' }, - { id: MAX_REASONING_EFFORT, name: 'Max' }, + { + id: OFF_REASONING_EFFORT, + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }, + { + id: LOW_REASONING_EFFORT, + name: 'Low', + description: 'Prefer for routine or latency-sensitive tasks.', + }, + { + id: HIGH_REASONING_EFFORT, + name: 'High', + description: 'The default balance for most tasks.', + }, + { + id: MAX_REASONING_EFFORT, + name: 'Max', + description: 'Reserve for the hardest quality-first tasks.', + }, ] as const const OFF_ONLY_REASONING_EFFORTS = [ - { id: OFF_REASONING_EFFORT, name: 'Off' }, + { + id: OFF_REASONING_EFFORT, + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }, ] as const /** Marks a failed file-id resolution that may be retried as an inline request. */ diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 0d895a5e3b..da0c9e24b5 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -82,8 +82,18 @@ const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' const PROVIDER = 'deepseek-official' const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ - { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW }, + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', + contextWindow: DEFAULT_CONTEXT_WINDOW, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + contextWindow: DEFAULT_CONTEXT_WINDOW, + }, { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9366c32ce8..3f87737930 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1205,7 +1205,11 @@ describe('DeepSeekAdapter against a mock server', () => { await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], defaultEffort: ReasoningEffortId('off'), }, }) @@ -1665,8 +1669,20 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', + inputModalities: ['text'], + }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, { provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) @@ -1678,10 +1694,10 @@ describe('plugin registration and config', () => { defaultMaxTokens: 256_000, reasoning: { efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('low'), name: 'Low' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, ], defaultEffort: ReasoningEffortId('high'), }, @@ -1708,10 +1724,10 @@ describe('plugin registration and config', () => { .resolves.toMatchObject({ reasoning: { efforts: [ - { id: ReasoningEffortId('off'), name: 'Off' }, - { id: ReasoningEffortId('low'), name: 'Low' }, - { id: ReasoningEffortId('high'), name: 'High' }, - { id: ReasoningEffortId('max'), name: 'Max' }, + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, ], defaultEffort: ReasoningEffortId(effort), }, @@ -1729,7 +1745,11 @@ describe('plugin registration and config', () => { await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) .resolves.toMatchObject({ reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], defaultEffort: ReasoningEffortId('off'), }, }) @@ -1761,7 +1781,11 @@ describe('plugin registration and config', () => { const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ reasoning: { - efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }], + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], defaultEffort: ReasoningEffortId('off'), }, }) @@ -1772,8 +1796,20 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', + inputModalities: ['text'], + }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, { provider: 'deepseek-official', id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] }, ]) }) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 42601edbd4..a4f3fed8d6 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: e204868e43ba07b78eff31293cc33cf790ff0085 -README.zh.md: f11fa5f092b9d4de1abb5c1464b6931d749c2970 +README.md: 3e92f02518c5a36412c9448a26d32958c217f79c +README.zh.md: 4600589749a063df924f9c961cc449506ba2af9f diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index e204868e43..3e92f02518 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -16,7 +16,7 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.mount(agentCtx, id?): Promise` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record. Refuses a broken preset up front with its discovery-reported reason, so every unloadable shape fails the same way before the loader is involved. - `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` Join one agent to the standing composition another already runs on, returning the preset id joined — `undefined` when the parent joined none, which is the rosterless deployment and not an error. A bind rather than a mount, so it is synchronous and has no composition failure mode; it still rejects a caller error (an unscoped context, or an agent that already joined). - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. -- `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. Refuses a broken preset like `mount()`. +- `ctx.agentPresets.recompose(agentCtx, id): Promise` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was. A committed re-link emits `tools/change`, because the Agent's resolved tool set changed without a registry entry changing. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn. Refuses a broken preset like `mount()`. - `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it. - `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index f11fa5f092..4600589749 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -16,7 +16,7 @@ - `ctx.agentPresets.mount(agentCtx, id?): Promise` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。对损坏的 preset 直接以发现时记下的原因拒绝,所以每种不可加载的形态都在加载器介入之前以同一方式失败。 - `ctx.agentPresets.composeFrom(agentCtx, parentCtx): string | undefined` 让一个 agent 加入另一个 agent 已在运行的常驻组装,返回所加入的 preset id——父方未加入任何 preset 时返回 `undefined`,那是无 roster 的部署,不是错误。这是认父而非挂载,因此同步、且自身没有组装失败模式;调用方用错(上下文无 scope、agent 已加入过)仍会拒绝。 - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 -- `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 +- `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。重链提交后会发出 `tools/change`,因为 Agent 解析到的工具集已经变化、但注册表条目本身没有增删。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。 - `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index dfdf08cbbe..a20ab0a87c 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { diff --git a/packages/preset/agent-presets/presets/code/agent.cordis.yml b/packages/preset/agent-presets/presets/code/agent.cordis.yml index 3333a980c0..1302329c25 100644 --- a/packages/preset/agent-presets/presets/code/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/code/agent.cordis.yml @@ -189,8 +189,13 @@ config: provider: spawn toolName: subagent + modelSelectionSettings: true backgroundMode: continuable + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. This preset + # keeps fork continuable and accepts its child-scoped `report` additions invalidating + # that prefix; issue #2124 tracks cache-preserving continuable fork. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml index f23907c655..b016eae3a1 100644 --- a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml @@ -176,8 +176,13 @@ config: provider: spawn toolName: subagent + modelSelectionSettings: true backgroundMode: continuable + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. This preset + # keeps fork continuable and accepts its child-scoped `report` additions invalidating + # that prefix; issue #2124 tracks cache-preserving continuable fork. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/preset/agent-presets/presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml index 5cb19e1e24..c21c5e4d79 100644 --- a/packages/preset/agent-presets/presets/standard/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/standard/agent.cordis.yml @@ -188,8 +188,13 @@ config: provider: spawn toolName: subagent + modelSelectionSettings: true backgroundMode: continuable + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. This preset + # keeps fork continuable and accepts its child-scoped `report` additions invalidating + # that prefix; issue #2124 tracks cache-preserving continuable fork. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index e22dee7275..8f44e2003c 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -27,6 +27,8 @@ import z from '@deepseek-ai/schemastery' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' +// Type-only: resolves the registry notification emitted after scope reparenting. +import type {} from '@deepseek-ai/dsh-tools' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts' @@ -455,7 +457,9 @@ export class AgentPresets extends Service { * state to restore. The re-link runs through the binding this roster kept * from the agent's mount — dsh-scope's only re-link authority. An agent * that never composed one has nothing to re-link: the switch is then the - * agent's first bind, exactly a mount. + * agent's first bind, exactly a mount. A committed re-link emits + * `tools/change` because changing the parent scope changes the Agent's + * resolved tool set without adding or removing registry entries. * @param agentCtx - the agent's scope context. * @param id - the preset to compose the agent from instead. * @returns the preset now installed. @@ -474,6 +478,14 @@ export class AgentPresets extends Service { } else { binding.rebind(standing.key) } + // Reparenting changes every scope-layered tool view without adding or + // removing a registration. Publish the registry's normal invalidation so + // Agent-owned overlays can reconcile with the new ancestry. + try { + this.ctx.emit('tools/change') + } catch (error: unknown) { + this.ctx.logger.warn(`agent-presets: tools/change listener failed after recomposing an Agent: ${String(error)}`) + } return preset } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 5b22e26ec6..83f6da3102 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -484,6 +484,27 @@ describe('replacing a composition', () => { expect(toolNames(ctx)).toEqual([]) }) + it('notifies tool views after reparenting and contains notification failures', async () => { + const handle = await ctx.agents.create({ + sessionId: SessionId('sess-tool-change'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + await ctx.agentPresets.standingKeyFor('minimal') + let changes = 0 + const stopCounting = ctx.on('tools/change', () => { changes += 1 }) + await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') + expect(changes).toBe(1) + stopCounting() + + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const stopThrowing = ctx.on('tools/change', () => { throw new Error('listener failed') }) + await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'standard')).resolves.toMatchObject({ id: 'standard' }) + expect(ctx.agentPresets.composedPreset(handle.agent.ctx)).toBe('standard') + expect(warnings).toEqual([expect.stringContaining('tools/change listener failed')]) + stopThrowing() + }) + it('leaves the agent on its previous composition when the new one is unknown', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('sess-unknown'), diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index 405ca21e2b..b40cf776ad 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -30,6 +30,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../core/tools" + }, { "path": "../../settings/settings" }, diff --git a/packages/sdk/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts index 0aa7099d0b..ae4ab8ef05 100644 --- a/packages/sdk/server/tests/built-scope-carrier.e2e.ts +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts @@ -66,7 +66,7 @@ try { const result = Promise.withResolvers(); const unregister = ctx.subagents.registerProvider({ name: "built-local", - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start() { return Promise.resolve({ diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index d9527e10d5..257c3e72b0 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -79,7 +79,7 @@ async function settleSubagent( const result = Promise.withResolvers() const disposeProvider = ctx.subagents.registerProvider({ name: info.provider, - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, async start() { return { @@ -538,7 +538,7 @@ describe('HarnessSdkJsonRpcServer', () => { let currentLocalAgent = oldChild.agent const disposeProvider = ctx.subagents.registerProvider({ name: 'reused', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start() { const result = results[starts] @@ -633,7 +633,7 @@ describe('HarnessSdkJsonRpcServer', () => { const remoteResult = Promise.withResolvers() const unregisterLocal = ctx.subagents.registerProvider({ name: 'reused-provider', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => Promise.resolve({ id: SessionId('provider-reuse-child'), @@ -651,7 +651,7 @@ describe('HarnessSdkJsonRpcServer', () => { const unregisterRemote = ctx.subagents.registerProvider({ name: 'reused-provider', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: () => Promise.resolve({ id: SessionId('provider-reuse-child'), @@ -731,7 +731,7 @@ describe('HarnessSdkJsonRpcServer', () => { const missedStartResult = Promise.withResolvers() const disposeMissedStartProvider = ctx.subagents.registerProvider({ name: 'fork', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: true, start: () => Promise.resolve({ id: SessionId('fallback-child-session'), diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index fa56c715a5..73f55ccfbf 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/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/subagent/subagent-acp/README.md -README.md: 0201f9bacca5c59031d2fc2eec7fe7210489dd09 -README.zh.md: c987e6251e035f0cf94ac41b570f3be10f0118f5 +README.md: cc4deb5b97152f106caabf747b8c7ccb2f5ddf8e +README.zh.md: e28fb556801d4567bcc606a777e3b090e0551e03 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 0201f9bacc..cc4deb5b97 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -18,7 +18,7 @@ After publication, the provider sends the prompt and collects streamed `agent_me ## Capabilities and context -ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary. +ACP advertises no start-time capabilities because this process cannot apply `request.agentOptions` or enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary. ## Configuration @@ -70,7 +70,7 @@ The package has no default export. Cordis loader unwrapping would otherwise hide #### What the model sees -The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. +The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for `agentOptions`, persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. #### Token effect @@ -98,6 +98,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)). - **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here. -- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them. +- **No optional start-time capabilities** — this provider cannot apply the local harness's `agentOptions`, `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them. - **Only committed `agent_message_chunk` text is collected** — the automation server keeps reasoning, tool activity, plans, and other trace data in the child session log rather than emitting them on ACP. - **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission`. diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index c987e6251e..e28fb55680 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -18,7 +18,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s ## 能力与上下文 -ACP 不声明任何启动时能力,因为当前进程无法强制执行远程子 agent 的深度、工具过滤、persona 或结构化输出运行时。它也报告 `inheritsParentContext: false`:远程会话从全新状态开始,唯一源自父级的输入是上述工作区 cwd;对话上下文不会跨越进程边界。 +ACP 不声明任何启动时能力,因为当前进程无法应用 `request.agentOptions`,也无法强制执行远程子 agent 的深度、工具过滤、persona 或结构化输出运行时。它也报告 `inheritsParentContext: false`:远程会话从全新状态开始,唯一源自父级的输入是上述工作区 cwd;对话上下文不会跨越进程边界。 ## 配置 @@ -70,7 +70,7 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D #### 模型看到的内容 -远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。该提供方不声明任何可选启动时能力,因此本地服务会拒绝要求 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 +远程子 agent 通过 ACP 接收独立任务内容,并使用其自身进程配置的系统提示词、工具和全新会话。它不接收父级对话。该提供方不声明任何可选启动时能力,因此本地服务会拒绝要求 `agentOptions`、persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 #### Token 影响 @@ -98,6 +98,6 @@ DeepSeek Harness 子进程使用产品启动器和一个显式的绝对路径 `D - **每次运行使用全新进程**:持久进程池属于后续优化(见 [seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md))。 - **仅支持本地工作区**:解析后的 cwd 是交给同一台机器上子进程的本地路径;远程 ACP agent 的工作区映射需要独立的后端能力,此处尚未设计这种能力。 -- **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。 +- **不支持可选启动时能力**:该提供方无法在远程进程内应用本地 harness 的 `agentOptions`、`outputSchema`、深度上限、工具过滤器或 persona,因此不会声明这些能力;服务会拒绝需要它们的请求。 - **只收集已提交的 `agent_message_chunk` 文本**:自动化服务器把推理(reasoning)、工具活动、计划和其他 trace 数据保留在子 agent 会话日志中,不通过 ACP 发出。 - **权限提示自动回答**(`permission: allow | reject`):不会把子 agent 的 `session/request_permission` 呈现给人。 diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 4b526279ba..4c610ef2f9 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -140,11 +140,17 @@ function resolveCwd(configured: string | undefined, request: SubagentStartReques /** * The ACP provider. Advertises NO start-time capabilities: an out-of-process - * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects - * a request needing any of them before `start` runs). + * child cannot honor `agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/ + * `persona` (the service rejects a request needing any before `start` runs). */ class AcpProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } + readonly capabilities: SubagentCapabilities = { + agentOptions: false, + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + } // Context contract: an out-of-process ACP child starts fresh — no parent conversation crosses the process boundary. readonly inheritsParentContext = false diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index c534b8e949..d4553d8f86 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -863,7 +863,13 @@ describe('dsh-subagent-acp', () => { it('advertises no start-time capabilities (out-of-process child)', async () => { const ctx = await setup() const provider = ctx.subagents.getProvider('acp')! - expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false }) + expect(provider.capabilities).toEqual({ + agentOptions: false, + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index ff129c60e2..f894fb15ab 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: 0260d9c82dee82541e5b60ff7fe2c323cf9b7331 -README.zh.md: 484241f1ca23f2b8ee843b6f412d0f779832d400 +README.md: 30b61347dfda110fe95a9153aeea35c8f760012f +README.zh.md: 17ddac7d4beebfbf0f374f606a68d77b13ec2b77 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 0260d9c82d..30b61347df 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -20,7 +20,7 @@ Each query sets `persistSession: false` and disables `AskUserQuestion`. Except i ## Capabilities and context -The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session. +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. The shared service rejects `request.agentOptions` for this provider. Claude Code receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. Every run has an independent SDK query, cancellation controller, CLI process, and non-persisted product session. ## Configuration @@ -145,5 +145,5 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **The SDK platform payload is required at delegation time** — installs that omit optional dependencies, unsupported platforms, and missing or damaged payloads fail at the first query; there is no host-CLI fallback. - **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. -- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No optional shared capabilities** — `agentOptions`, output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 484241f1ca..17ddac7d4b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -20,7 +20,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 能力与上下文 -本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。 +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。共享服务会拒绝本提供方的 `request.agentOptions`。Claude Code 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。每次运行都拥有独立的 SDK query、取消控制器、CLI 进程和不持久化的产品会话。 ## 配置 @@ -145,5 +145,5 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **委派时必须存在 SDK 平台载荷**:省略 optional dependencies 的安装、不受支持的平台以及缺失或损坏的载荷都会在第一次 query 时失败;不会回退到宿主 CLI。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 -- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝 `agentOptions`、输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index 5f339ce22e..6054370615 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -45,6 +45,7 @@ describe('product-provider public Loader composition', () => { { name: 'codex', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -55,6 +56,7 @@ describe('product-provider public Loader composition', () => { { name: 'claude-code', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -65,6 +67,7 @@ describe('product-provider public Loader composition', () => { { name: 'claude-primary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -75,6 +78,7 @@ describe('product-provider public Loader composition', () => { { name: 'claude-secondary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 0f8ed31eab..fd0cccc02d 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent/subagent-codex/README.md -README.md: 975f353b9f1bc6fab61a4c0eb40ebaf50c436623 -README.zh.md: 2ea256afb3bb8fdfe57fd555b6db10522a709a56 +README.md: 5016d6b9aa4b57c9d1e83701a0ccfc4b04616f6d +README.zh.md: adabd942c362a28a4eeabe7a4d1b27b138aee4c6 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 975f353b9f..5016d6b9aa 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -18,7 +18,7 @@ Local cancellation wins the result race and maps to `aborted`. For failed turns, ## Capabilities and context -The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. +The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. The shared service rejects `request.agentOptions` for this provider. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session. ## Configuration @@ -139,5 +139,5 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. -- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. +- **No optional shared capabilities** — `agentOptions`, output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 2ea256afb3..adabd942c3 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -18,7 +18,7 @@ ## 能力与上下文 -本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 +本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。共享服务会拒绝本提供方的 `request.agentOptions`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。 ## 配置 @@ -139,5 +139,5 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 -- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 +- **没有可选的共享能力**:对于本提供方,共享服务会拒绝 `agentOptions`、输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index b55b819989..c411ae2dbf 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -45,6 +45,7 @@ describe('Codex provider public Loader composition', () => { { name: 'codex', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -55,6 +56,7 @@ describe('Codex provider public Loader composition', () => { { name: 'codex-primary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, @@ -65,6 +67,7 @@ describe('Codex provider public Loader composition', () => { { name: 'codex-secondary', capabilities: { + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index 8946de4236..a05ec421e3 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/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/subagent/subagent-dsh-sdk/README.md -README.md: 10f437f1618d006f58ad37701e6fde7944f5c1fb -README.zh.md: d3237d9a0b36ce63c9eacf85bfe7cff947acd9e0 +README.md: 302baa05afed2b78c2041f57b1ef900713e350cd +README.zh.md: e4e1460274170b0c990d9e454f1cbf54546676e9 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 10f437f161..302baa05af 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -20,7 +20,7 @@ The SDK client returns an owned child activity rather than a prompt result. The ## Capabilities and context -The provider advertises no start-time capabilities (`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. +The provider advertises no start-time capabilities (`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` all false) and `inheritsParentContext: false`: the child is a fresh runtime in another process, and the only parent-derived input is the workspace cwd. `dsh-tool-subagent` deployments over this provider set `maxDepth: 'provider-managed'` — the child harness owns its own recursion budget. ## Configuration @@ -68,7 +68,7 @@ The package has no default export. Cordis loader unwrapping would otherwise hide #### What the model sees -The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. +The child runtime's model receives the standalone task as its user message plus that runtime's own configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for `agentOptions`, persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. #### Token effect @@ -95,6 +95,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **A fresh runtime process per run** — no pooling; a harness runtime boots a full plugin tree, so per-run spawn cost is higher than the ACP backend's typical child. -- **No optional start-time capabilities** — the parent cannot enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead. +- **No optional start-time capabilities** — the parent cannot apply `agentOptions` or enforce `outputSchema`, depth, tool filters, or persona inside the child process; configure the selected child profile and its ordered patches instead. - **The child's transcript stays in the child's own session root** — the parent log records only the delegation tool call/result (the seam's child-isolation rule); the streamed `session.event` channel is consumed for output extraction, not bridged into the parent log. - **Local child processes only** — the resolved cwd is a local path; a remote runtime would need its own backend. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index d3237d9a0b..e4e1460274 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -20,7 +20,7 @@ SDK 客户端返回自有子活动,而不是提示词结果。提供方读取 ## 能力与上下文 -Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilter`/`persona` 全为 false),且 `inheritsParentContext: false`:子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider 的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 +Provider 不宣告任何启动期能力(`agentOptions`/`outputSchema`/`depthLimit`/`toolFilter`/`persona` 全为 false),且 `inheritsParentContext: false`:子进程是另一进程里的全新运行时,唯一来自父方的输入是工作区 cwd。基于本 provider 的 `dsh-tool-subagent` 部署应设置 `maxDepth: 'provider-managed'`——子 harness 拥有自己的递归预算。 ## 配置 @@ -68,7 +68,7 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte #### 模型看到的内容 -子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。本提供方不声明可选的启动时能力,因此本地服务会拒绝要求 persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 +子运行时的模型会收到作为用户消息的独立任务,以及该运行时自身配置的系统提示词、工具和全新会话。它不会收到父级对话。本提供方不声明可选的启动时能力,因此本地服务会拒绝要求 `agentOptions`、persona、工具过滤、深度强制或结构化输出的请求,而不是静默省略这些要求。 #### Token 影响 @@ -95,6 +95,6 @@ Provider 不宣告任何启动期能力(`outputSchema`/`depthLimit`/`toolFilte ## 已知限制与暂缓事项 - **每次运行都使用全新的运行时进程**:不使用进程池;harness 运行时需要启动完整的插件树,因此每次运行的 spawn 成本高于 ACP 后端通常使用的子进程。 -- **不支持可选的启动时能力**:父级无法在子进程内强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。 +- **不支持可选的启动时能力**:父级无法在子进程内应用 `agentOptions`,也无法强制执行 `outputSchema`、深度限制、工具过滤或 persona;应改为配置所选子 profile 及其有序 patch。 - **子进程的 transcript(文本记录)保留在其自身的会话根目录中**:父级日志只记录委派工具调用/结果(seam 的子级隔离规则);流式 `session.event` 通道只用于提取输出,不会桥接到父级日志中。 - **仅支持本地子进程**:解析出的 cwd 是本地路径;远程运行时需要独立的后端。 diff --git a/packages/subagent/subagent-dsh-sdk/src/index.ts b/packages/subagent/subagent-dsh-sdk/src/index.ts index 7530e104d2..277add74f8 100644 --- a/packages/subagent/subagent-dsh-sdk/src/index.ts +++ b/packages/subagent/subagent-dsh-sdk/src/index.ts @@ -105,7 +105,7 @@ function resolveConfiguredFile(field: string, value: string): string { /** * The SDK provider. Advertises NO start-time capabilities: an out-of-process - * child cannot honor `outputSchema`/`maxDepth`/`toolFilter`/`persona` (the + * child cannot honor `agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/`persona` (the * service rejects a request needing any of them before `start` runs). */ class SdkSubagentProvider implements SubagentProvider { diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 2e67c76c24..27511670cf 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -433,6 +433,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { expect(ctx.subagents.getProvider('sdk-hmr')?.name).toBe('sdk-hmr') expect(ctx.subagents.getProvider('sdk-hmr')?.inheritsParentContext).toBe(false) expect(ctx.subagents.getProvider('sdk-hmr')?.capabilities).toEqual({ + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index 9cd2361d52..19af9e598a 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-process/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/subagent/subagent-fork-in-process/README.md -README.md: 74c27ff10c76aa711ed3e954e806c00a27aacfa5 -README.zh.md: 43e7ef489b33d52b674420d08f7edf8c89fb0e42 +README.md: c2dcda39c03b8c059839bef1573436e485a51911 +README.zh.md: 3eb84053a3e74abe7394c0985dcd88bd6146e449 diff --git a/packages/subagent/subagent-fork-in-process/README.md b/packages/subagent/subagent-fork-in-process/README.md index 74c27ff10c..c2dcda39c0 100644 --- a/packages/subagent/subagent-fork-in-process/README.md +++ b/packages/subagent/subagent-fork-in-process/README.md @@ -16,7 +16,7 @@ The seed transfers conversation history only. The child still receives a fresh f `start(request)` passes the completed-turn seed to [`startInProcessRun`](../subagent-in-process-driver/README.md) and awaits child publication. The shared driver owns cancellation, depth, customization, result reading, and disposal. -Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn. +Fork advertises `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`, identical to spawn. ## Config @@ -39,7 +39,7 @@ Forking duplicates retained completed history into separate child requests; the #### KV Cache effect -The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. Shipped compositions therefore bind this provider to `backgroundMode: one-shot`, because a continuable child additionally carries the child-scoped `report` tool and its prompt section — deltas that precede the inherited history and so invalidate all of it ([the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)). +The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. The base bundle and ACP/headless examples bind this provider to `backgroundMode: one-shot`, because a continuable child additionally carries the child-scoped `report` tool and its prompt section — deltas that precede the inherited history and so invalidate all of it. The CLI presets retain `continuable` fork and therefore accept that prefix loss ([the cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md)). ### Parent tool result, indirectly @@ -58,4 +58,5 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing. -- **No shipped composition creates a continuable fork child** — `prepareContinuable` remains implemented and the seam accepts it, but every shipped `cordis.yml` sets `backgroundMode: one-shot` on the fork delegation tool, so the provider's continuable path has no production caller. Reopening it requires the child's system prompt and tool schemas to match the parent's byte for byte, which the [`report` return channel](../tool-subagent-report/README.md) currently prevents. Rationale and the reintroduction condition: [the fork-one-shot Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). +- **Fork lifecycle policy differs by composition** — the base bundle and ACP/headless examples use one-shot fork to preserve prefix reuse, while the CLI presets use continuable fork and accept the child-scoped [`report` return channel](../tool-subagent-report/README.md) invalidating that prefix. Making continuable fork cache-preserving requires the child system prompt and tool schemas to match the parent's byte for byte. Rationale and the reintroduction condition: [the cache-preserving fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md). +- **Shipped fork tools do not expose child LLM route selection** — they inherit the parent's provider and model so the copied history remains eligible for KV Cache reuse. Route selection stays disabled until a change can preserve reuse or expose a bounded recomputation cost; the [model-selected route Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md) owns that separate restriction. diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index 43e7ef489b..3eb84053a3 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -16,7 +16,7 @@ subagent 启动时,父 agent 当前的工具调用轮次仍未结束:其日 `start(request)` 将已完成轮次的初始内容传给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并等待子 agent 发布。共享驱动器负责取消、深度、定制、结果读取和 dispose(资源释放)。 -fork 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,与 spawn 相同。 +fork 声明 `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,与 spawn 相同。 ## 配置 @@ -39,7 +39,7 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 #### KV Cache 影响 -在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。因此随附组合把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效(见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md))。 +在提供方和模型相同的前提下,子 agent 可以复用继承的逐字节相同前缀。persona、工具过滤、生成 SDK 或路由变化可能在继承历史之前使复用失效;后续子 agent 历史仅追加。base 组合包与 ACP/headless 示例把本提供方绑定为 `backgroundMode: one-shot`:可继续子 agent 还会额外携带作用域局部的 `report` 工具及其提示词 section,而这些增量位于继承历史之前,会使继承历史整体失效。CLI preset 保留可继续 fork,因此接受这项前缀损失(见[保留缓存的 fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md))。 ### 父 agent 工具结果(间接) @@ -58,4 +58,5 @@ fork 会把保留的已完成历史复制到独立的子 agent 请求中;随 ## 已知限制与暂缓事项 - **初始内容是一次性快照**:子 agent 只能看到 fork 时父 agent 已完成的轮次,看不到父 agent 此后记录的任何内容;不会实时共享上下文。 -- **没有任何随附组合会创建可继续的 fork 子 agent**:`prepareContinuable` 仍然实现完好,seam 也接受它,但每份随附的 `cordis.yml` 都在 fork 委派工具上设置 `backgroundMode: one-shot`,因此该提供方的可继续路径没有生产调用方。重新开放它需要子 agent 的系统提示词与工具 schema 与父 agent 逐字节一致,而这一点目前被 [`report` 返回通道](../tool-subagent-report/README.zh.md)阻止。理由与重新开放条件见 [fork 保持 one-shot 的 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 +- **fork 生命周期策略因组合而异**:base 组合包与 ACP/headless 示例使用一次性 fork 以保留前缀复用,CLI preset 则使用可继续 fork,并接受子级作用域的 [`report` 返回通道](../tool-subagent-report/README.zh.md)使该前缀失效。要让可继续 fork 保留缓存,子 agent 的系统提示词与工具 schema 必须与父级逐字节一致。理由与重新开放条件见[保留缓存的 fork Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.zh.md)。 +- **随附 fork 工具不公开子级 LLM 路由选择**:它们会继承父级的提供方与模型,使复制的历史仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才启用路由选择;该独立限制由[模型选择路由 Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md)负责。 diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 1f8e48b8c8..9786f585fa 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -55,11 +55,18 @@ function completedTurnPrefix(parent: Agent): SessionEvent[] { /** * The fork provider. Supports `depthLimit` and `outputSchema` (via the shared - * in-process structured runtime), plus `toolFilter`/`persona` (scoped - * restrict() and a scoped shadowing persona section). + * in-process structured runtime), `agentOptions` (merged over the parent + * route), and `toolFilter`/`persona` (scoped restrict() and a scoped shadowing + * persona section). */ class ForkInProcessProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } + readonly capabilities: SubagentCapabilities = { + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + } // Context contract: a forked child IS seeded with the parent's completed-turn prefix. readonly inheritsParentContext = true @@ -74,11 +81,11 @@ class ForkInProcessProvider implements SubagentProvider { }) } - // TODO(fork-continuable-prefix-reuse): no shipped composition calls this — - // they bind fork to `backgroundMode: one-shot` because a continuable child's - // `report` tool and prompt section precede the inherited history, defeating - // the prefix reuse a fork exists for. Reopening needs a byte-identical child - // system prompt and tool schemas; see issue #2124 and + // TODO(fork-continuable-prefix-reuse): CLI presets call this and accept that + // a continuable child's `report` tool and prompt section precede the inherited + // history, defeating the prefix reuse a fork exists for. Cache-preserving + // continuable fork needs byte-identical child system prompt and tool schemas; + // see issue #2124 and // .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. prepareContinuable(request: ContinuableCreateRequest): Promise { // The fork prefix is captured ONCE, at creation: it becomes part of the diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 8b69711233..292fba32cc 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -193,9 +193,15 @@ describe('dsh-subagent-fork-in-process', () => { await run.dispose() }) - it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => { + it('advertises every start-time capability', async () => { const { ctx } = await setup([]) - expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }) + expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml index 505c480cfe..16d07d8211 100644 --- a/packages/subagent/subagent-in-process-driver/README.i18n.yaml +++ b/packages/subagent/subagent-in-process-driver/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/subagent/subagent-in-process-driver/README.md -README.md: 47a5c09fc1c80c5dc3062be82e7355b874a627d3 -README.zh.md: b96399795a0fbac05ef1795888aa93c620687f30 +README.md: ed2568fcff3fe1f0f3968d1cef43ebd914a8911b +README.zh.md: f9958e5c2b819d51bfdf8fc1e14d1f8c7c19be91 diff --git a/packages/subagent/subagent-in-process-driver/README.md b/packages/subagent/subagent-in-process-driver/README.md index 47a5c09fc1..ed2568fcff 100644 --- a/packages/subagent/subagent-in-process-driver/README.md +++ b/packages/subagent/subagent-in-process-driver/README.md @@ -16,7 +16,7 @@ The driver follows this sequence: 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. 5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed. -The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. +The child gets the parent's working-directory/session lineage and inherits the parent provider, model, reasoning effort, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md index b96399795a..f9958e5c2b 100644 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ b/packages/subagent/subagent-in-process-driver/README.zh.md @@ -16,7 +16,7 @@ 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 -子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 +子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型、推理强度与输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 8840e8b101..6bb18f5f28 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -70,7 +70,7 @@ async function setup(script: Script, options: SetupOptions = {}) { await ctx.plugin(SubagentRuntime) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: false, persona: false }, inheritsParentContext: false, start: (request: ResolvedSubagentStartRequest) => startInProcessRun(request, {}), }) diff --git a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml index 1246456cc0..022dbfa58d 100644 --- a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-spawn-in-process/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/subagent/subagent-spawn-in-process/README.md -README.md: f1fb96f2230359cb3ff55c630f29fd34345dbed7 -README.zh.md: 95a3b5cdb7084eb75666f8d62001221c57ac676c +README.md: ebe2b069dc56dc1a3359f8880860a5796ef3ef4c +README.zh.md: 66ecbec2c00865d16a99f1e6bf4f0c32cb6e5538 diff --git a/packages/subagent/subagent-spawn-in-process/README.md b/packages/subagent/subagent-spawn-in-process/README.md index f1fb96f223..ebe2b069dc 100644 --- a/packages/subagent/subagent-spawn-in-process/README.md +++ b/packages/subagent/subagent-spawn-in-process/README.md @@ -6,13 +6,13 @@ The spawn provider creates a fresh child `Agent` in the current process. The chi ## Behavior -`start(request)` delegates to [`startInProcessRun`](../subagent-in-process-driver/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent model unless overridden, but starts with an empty conversation. +`start(request)` delegates to [`startInProcessRun`](../subagent-in-process-driver/README.md) with no seed and awaits publication before returning. The child receives parent working-directory/session lineage and inherits the parent provider, model, reasoning effort, and output-token limit unless `request.agentOptions` overrides them, but starts with an empty conversation. The shared driver owns depth checking, persona and tool-filter setup, structured output, required-signal cancellation, one-shot execution, result reading, and quiescent disposal. A startup rejection leaves no published child; provider unload after fulfillment does not revoke the holder-owned run. ## Capabilities -Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all four features. +Spawn advertises `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }` because it controls the child's creation window and can enforce all five features. ## Config @@ -26,7 +26,7 @@ Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, pers #### What the model sees -The fresh child receives the standalone task content verbatim, inherits the parent model and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent. +The fresh child receives the standalone task content verbatim, inherits the parent provider, model, reasoning effort, output-token limit, and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent. #### Token effect @@ -52,4 +52,4 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required. +- **Fresh means no parent transcript** — the child inherits cwd, lineage, provider, model, reasoning effort, output-token limit, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required. diff --git a/packages/subagent/subagent-spawn-in-process/README.zh.md b/packages/subagent/subagent-spawn-in-process/README.zh.md index 95a3b5cdb7..66ecbec2c0 100644 --- a/packages/subagent/subagent-spawn-in-process/README.zh.md +++ b/packages/subagent/subagent-spawn-in-process/README.zh.md @@ -6,13 +6,13 @@ spawn 提供方会在当前进程中创建一个全新的子 `Agent`。子 agent ## 行为 -`start(request)` 不传入 seed,直接委托给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并在子 agent 发布后才返回。子 agent 获得父 agent 的工作目录/会话谱系,并默认继承父 agent 模型(除非覆盖),但以空对话开始运行。 +`start(request)` 不传入 seed,直接委托给 [`startInProcessRun`](../subagent-in-process-driver/README.zh.md),并在子 agent 发布后才返回。子 agent 获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型、推理强度与输出 token 上限,但以空对话开始运行。 共享驱动器负责深度检查、persona 与工具过滤器设置、结构化输出、通过必需的信号执行取消、单次执行、结果读取和完全停稳后的 dispose(资源释放)。启动遭拒不会留下已发布的子 agent;启动调用兑现后卸载提供方,也不会撤销由持有方拥有的运行。 ## 能力 -spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,因为它控制子 agent 的创建窗口,能够强制执行全部四项功能。 +spawn 声明 `{ agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }`,因为它控制子 agent 的创建窗口,能够强制执行全部五项功能。 ## 配置 @@ -26,7 +26,7 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: #### 模型看到的内容 -全新的子 agent 逐字接收独立任务内容,默认继承父 agent 的模型和工作区,并看到带有已配置子 agent 作用域 persona 遮蔽的全局提示词。工具过滤器会为该子 agent 移除全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但保留独立注册的指导内容。它不接收任何父 agent 对话消息;过滤控制的是可见性与组合,并非从父 agent 继承的权限授予。 +全新的子 agent 逐字接收独立任务内容,默认继承父 agent 的提供方、模型、推理强度、输出 token 上限与工作区,并看到带有已配置子 agent 作用域 persona 遮蔽的全局提示词。工具过滤器会为该子 agent 移除全局协议 schema、可执行工具查找和 Code Mode SDK 绑定,但保留独立注册的指导内容。它不接收任何父 agent 对话消息;过滤控制的是可见性与组合,并非从父 agent 继承的权限授予。 #### Token 影响 @@ -52,4 +52,4 @@ spawn 声明 `{ outputSchema: true, depthLimit: true, toolFilter: true, persona: ## 已知限制与暂缓事项 -- **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、模型及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 +- **全新表示不含父 agent transcript(文本记录)**:子 agent 会继承 cwd、谱系、提供方、模型、推理强度、输出 token 上限及显式配置的 persona/工具限制,但不继承父 agent 的任何对话;需要已完成轮次上下文时,请使用 fork 提供方。 diff --git a/packages/subagent/subagent-spawn-in-process/src/index.ts b/packages/subagent/subagent-spawn-in-process/src/index.ts index dcd036e4ad..73811155c1 100644 --- a/packages/subagent/subagent-spawn-in-process/src/index.ts +++ b/packages/subagent/subagent-spawn-in-process/src/index.ts @@ -34,12 +34,18 @@ export const Config: z = z.object({ /** * The spawn provider. Supports every start-time capability: `depthLimit` (it * constructs the child, so it can enforce a recursion cap), `outputSchema` - * (the scoped structured runtime), and `toolFilter`/`persona` (scoped - * `restrict()` and a scoped shadowing persona section, applied in the child's - * creation window). + * (the scoped structured runtime), `agentOptions` (merged over the parent + * route), and `toolFilter`/`persona` (scoped `restrict()` and a scoped + * shadowing persona section, applied in the child's creation window). */ class SpawnInProcessProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } + readonly capabilities: SubagentCapabilities = { + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + } // Context contract: a spawned child starts fresh — it never sees the parent conversation. readonly inheritsParentContext = false diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index ae60480c02..7a798a4818 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -282,10 +282,16 @@ describe('dsh-subagent-spawn-in-process', () => { await parentHandle.dispose() }) - it('advertises every start-time capability (depthLimit, outputSchema, toolFilter, persona)', async () => { + it('advertises every start-time capability', async () => { const { ctx } = await setup([]) const provider = ctx.subagents.getProvider('spawn')! - expect(provider.capabilities).toEqual({ outputSchema: true, depthLimit: true, toolFilter: true, persona: true }) + expect(provider.capabilities).toEqual({ + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, + }) }) it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index a645351bd8..a55a61ee77 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: e84a6b486253e81ccf7e7df12c4149e6df4ed9f2 -README.zh.md: e289863531c1686cedeccadfa76e2661dfa9bfc8 +README.md: 68ddc49197bcbd3f8eb5f362de60da33cb08c147 +README.zh.md: cf434152cd6366e371eef86f0edcb08d18978c66 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index e84a6b4862..68ddc49197 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -26,7 +26,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. | | `listDescendants(rootSessionId, signal?)` | Flatten the root's complete session tree in stable pre-order from the same live-preferred corpus, adding each subagent entry's durable `parentId` and root-relative `depth`. Ordinary sessions and one-shot children remain traversal nodes so continuable descendants below them are discovered. Identity, diagnostics, dependencies, and cancellation follow `listChildren()`. | -`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. +`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also override the host Agent's provider, model, reasoning effort, and token limit, require structured output, cap delegation depth, restrict child tools, or set a child persona. Every requested optional feature requires its matching provider capability. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child. Follow-up authority comes from the exact live direct parent recorded in the child's durable header. Cold resume checks that authority before reconstruction and again in the final no-await inbox-admission span, so a parent unregistered or replaced during materialization cannot authorize delivery. The `source` on a follow-up records who supplied the delivered message and grants no authority. @@ -36,11 +36,14 @@ Same-process requests, descriptors, results, and event payloads are trusted type Start-time features are advertised in `provider.capabilities` because the service must reject an unsupported one-shot request before child creation: +- `agentOptions` — apply host-Agent provider, model, reasoning-effort, and output-token overrides. - `outputSchema` — enforce a structured final result. - `depthLimit` — enforce `maxDepth`. - `toolFilter` — apply the requested child tool restriction. - `persona` — apply a per-child persona. +Both in-process providers advertise `agentOptions`: child creation merges requested fields over the provider, model, and reasoning effort in the parent's latest logged request, falling back to its creation options before the first request and retaining its configured token limit. A route change without an explicit effort clears the inherited route-owned effort so the selected model resolves its default. Current out-of-process providers advertise it as unsupported, so configured or model-selected overrides fail before their child transport starts instead of being silently ignored. + Every in-process child is composed by one call, `applyChildComposition(childCtx, parent, composition)`, which joins the parent's agent-preset composition before applying the child's own persona and tool filter. The join is what gives the child its capabilities: with every model-facing row on the agent plane, a child that joined nothing would reach the model with an empty tool registry ([`dsh-agent-presets`](../../preset/agent-presets/README.md)). Taking the parent as a parameter is deliberate — it makes composing a child WITHOUT that join unrepresentable at the call sites, which is the defect the one call exists to prevent. A deployment composing no preset roster joins nothing and needs nothing: its model-facing rows sit in the host composition, where the child already resolves them through the tool registry's global layer. `childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. @@ -49,7 +52,7 @@ Continuable creation is the optional `SubagentProvider.prepareContinuable?()` me ## The durable descriptor -The Service Definition owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the provider name and lifecycle `mode`. A `one-shot` descriptor optionally carries the caller-owned durable display `label`; a `continuable` descriptor requires its durable creation label and additionally records resolved child `agentOptions.provider`/`model` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an Activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime. +The Service Definition owns the versioned `subagent/descriptor` session event vocabulary (`src/descriptor.ts`): `snapshotSubagentDescriptor()` validates and detaches the record before provider work, and `foldSubagentDescriptor()` validates the complete current-version payload before recovering it from a loaded child log. Every local session-backed start appends one descriptor with the provider name and lifecycle `mode`. A `one-shot` descriptor optionally carries the caller-owned durable display `label`; a `continuable` descriptor requires its durable creation label and additionally records resolved child `agentOptions.provider`/`model`/`reasoningEffort` and optional `persona`/`toolFilter` for cold resume. These are explicit fields, never the merge-extensible `AgentOptions` object, so an unrelated extension value cannot break continuation. The descriptor omits `subagentDepth` (the persisted header's `delegationDepth` is the monotone floor) and `outputSchema` (an Activation's result contract). The event is log-only: no `surfaceOp`, absent from model history, and retained by the append-only log across compaction. Malformed current-version payloads are corrupt; unsupported versions cannot be classified by this runtime. ## Delegation depth diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index e289863531..cf434152cd 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -26,7 +26,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `listChildren(parentSessionId, signal?)` | 按 `createdAt`、再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、根据 origin 分类得出的一层 `hasChildren` 提示,以及每个子级的诊断信息,且不会加载或恢复它们。该操作直接读取在线会话存储和可选的会话持久化(没有持久化时只枚举在线子级),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 | | `listDescendants(rootSessionId, signal?)` | 从同一份在线优先语料按稳定 pre-order 展平根的完整会话树,并为每个 subagent 条目附加持久 `parentId` 与相对根的 `depth`。普通会话与一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现。身份、diagnostic、依赖与取消约定均沿用 `listChildren()`。 | -`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只负责 inbox 接受前的查找、物化和准入;此后,Activation 由管理器独立拥有,因此调用方取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。 +`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以覆盖宿主 Agent 的提供方、模型、推理强度与 token 上限、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。每个被请求的可选特性都要求匹配的提供方能力。对于可继续启动或后续操作,调用方信号只负责 inbox 接受前的查找、物化和准入;此后,Activation 由管理器独立拥有,因此调用方取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。 后续操作的权限来自子 agent 持久化 header 中记录的确切在线直接父级。冷恢复会在重建前检查该权限,并在最终无 await 的 inbox 准入区间再次检查,因此在物化期间被注销或替换的 parent 无法授权投递。后续操作上的 `source` 记录谁提供了所投递的消息,不授予任何权限。 @@ -36,11 +36,14 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 启动时功能通过 `provider.capabilities` 声明,因为服务必须在创建子 agent 前拒绝不受支持的一次性请求: +- `agentOptions`:应用宿主 Agent 提供方、模型、推理强度与输出 token 上限覆盖; - `outputSchema`:强制执行结构化最终结果; - `depthLimit`:强制执行 `maxDepth`; - `toolFilter`:应用请求的子 agent 工具限制; - `persona`:应用每个子 agent 独立的 persona。 +两个进程内提供方都会声明 `agentOptions`:创建子 agent 时,请求字段会覆盖父级最新记录请求中的提供方、模型与推理强度;首个请求之前回退到其创建选项,并保留其中配置的 token 上限。更换路由但没有显式指定强度时,会清除继承的路由所属强度,使所选模型解析自己的默认值。当前进程外提供方会声明不支持,因此配置或模型选择的覆盖会在启动子传输前失败,而不会被静默忽略。 + 每个进程内子 agent 都通过一次 `applyChildComposition(childCtx, parent, composition)` 调用完成组装:先加入父级的 agent-preset 组合,再应用子 agent 自己的 persona 和工具限制。加入父级组合正是子 agent 获得能力的途径:所有面向模型的行都位于 agent 平面,完全没有加入任何组合的子 agent 抵达模型时会看到空的工具注册表(见 [`dsh-agent-presets`](../../preset/agent-presets/README.zh.md))。将父级作为参数是刻意设计:这让“组装子 agent 却不做该加入”在各调用点无法表达,而这正是这一次调用所要杜绝的缺陷。未组装 preset roster 的部署不加入任何组合、也不需要加入;其面向模型的行位于宿主组合中,子 agent 已能通过工具注册表的全局层解析到它们。 `childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 @@ -49,7 +52,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 持久化描述符 -该 Service Definition 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label`;`continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider`/`model`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果约定)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩(compaction)保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。 +该 Service Definition 拥有版本化的 `subagent/descriptor` 会话事件词汇(`src/descriptor.ts`):`snapshotSubagentDescriptor()` 会在提供方工作之前校验并分离记录,`foldSubagentDescriptor()` 则会在从已加载子 agent 日志中恢复描述符之前,校验当前版本的完整 payload。每次由本地会话支撑的启动都会追加一个带有提供方名称与生命周期 `mode` 的描述符。`one-shot` 描述符可以携带调用方拥有的可选持久化显示 `label`;`continuable` 描述符要求其持久化创建标签,并另外记录已解析的子 agent `agentOptions.provider`/`model`/`reasoningEffort`,以及用于从持久化存储恢复的可选 `persona`/`toolFilter`。这些是显式字段,绝不是可通过合并扩展的 `AgentOptions` 对象,因此无关的扩展值不会破坏继续执行。描述符省略 `subagentDepth`(持久化 header 的 `delegationDepth` 是单调下界)和 `outputSchema`(单次 Activation 的结果约定)。该事件只进入日志:不含 `surfaceOp`,不进入模型历史,并由仅追加日志跨压缩(compaction)保留。格式错误的当前版本 payload 属于损坏;本运行时无法对不受支持的版本进行分类。 ## 委派深度 diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 7582338858..22c9e77bf5 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -57,9 +57,38 @@ export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): } /** - * Resolve the child's `AgentOptions`: the parent's provider/model/maxTokens - * route unless the request overrides it, stamped with the child's own - * delegation depth. + * Resolve the parent values inherited by a child. The latest request header + * owns provider, model, and reasoning effort after request-time selection; + * creation options remain the fallback before the first request and retain + * the configured output-token limit. + * @param parent - delegating parent Agent. + * @returns detached Agent options for child-option merging. + */ +export function parentAgentOptionsForDelegation(parent: Agent): AgentOptions { + const requestConfig = parent.session.requestHeader()?.config + if (requestConfig === undefined) return { ...parent.options } + const { + provider: _createdProvider, + model: _createdModel, + reasoningEffort: _createdReasoningEffort, + ...createdOptions + } = parent.options + return { + ...createdOptions, + provider: requestConfig.provider, + model: requestConfig.model, + ...requestConfig.reasoningEffort === undefined + ? {} + : { reasoningEffort: requestConfig.reasoningEffort }, + } +} + +/** + * Resolve the child's `AgentOptions`: the parent's provider/model, + * reasoning-effort, and maxTokens values unless the request overrides them, + * stamped with the child's own delegation depth. Changing the route without + * naming an effort clears the parent's route-owned effort so the selected + * model resolves its own default. * @param parent - the delegating parent whose route the child inherits. * @param requested - per-child overrides, if any. * @param childDepth - the resolved delegation depth to stamp. @@ -70,16 +99,22 @@ export function resolveChildAgentOptions( requested: AgentOptions | undefined, childDepth: number, ): AgentOptions { - const parentProvider = parent.options.provider - const parentModel = parent.options.model - const parentMaxTokens = parent.options.maxTokens - return { + const parentOptions = parentAgentOptionsForDelegation(parent) + const parentProvider = parentOptions.provider + const parentModel = parentOptions.model + const parentReasoningEffort = parentOptions.reasoningEffort + const parentMaxTokens = parentOptions.maxTokens + const resolved: AgentOptions = { ...parentProvider !== undefined ? { provider: parentProvider } : {}, ...parentModel !== undefined ? { model: parentModel } : {}, + ...parentReasoningEffort !== undefined ? { reasoningEffort: parentReasoningEffort } : {}, ...parentMaxTokens !== undefined ? { maxTokens: parentMaxTokens } : {}, ...requested, subagentDepth: childDepth, } + const routeChanged = resolved.provider !== parentProvider || resolved.model !== parentModel + if (routeChanged && requested?.reasoningEffort === undefined) delete resolved.reasoningEffort + return resolved } /** diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 652a3ba6c8..2588c1a699 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -30,7 +30,7 @@ import type { AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' -import { boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { ReasoningEffortId, boundContextSummary, createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -417,14 +417,17 @@ export class SubagentContinuationManager { const childDepth = resolveChildDepth(parent, request.maxDepth) // Snapshot before any await: invalid descriptor JSON rejects the call // before a child exists, and the detached value is what reaches the log. - const agentProvider = request.agentOptions?.provider ?? parent.options.provider - const agentModel = request.agentOptions?.model ?? parent.options.model + const agentOptions = resolveChildAgentOptions(parent, request.agentOptions, childDepth) + const agentProvider = agentOptions.provider + const agentModel = agentOptions.model + const agentReasoningEffort = agentOptions.reasoningEffort const descriptor = snapshotSubagentDescriptor({ mode: 'continuable', provider: spec.provider, label: spec.label, ...agentProvider !== undefined ? { agentProvider } : {}, ...agentModel !== undefined ? { agentModel } : {}, + ...agentReasoningEffort !== undefined ? { agentReasoningEffort } : {}, ...request.persona !== undefined ? { persona: request.persona } : {}, ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) @@ -460,7 +463,7 @@ export class SubagentContinuationManager { provider: spec.provider, parent, create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies }, - agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), + agentOptions, composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, }) @@ -981,6 +984,9 @@ export class SubagentContinuationManager { agentOptions: { ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {}, ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {}, + ...descriptor.agentReasoningEffort !== undefined + ? { reasoningEffort: ReasoningEffortId(descriptor.agentReasoningEffort) } + : {}, }, composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter }, signal: options.signal, diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 6d9dedee75..9a25c382b1 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -23,6 +23,7 @@ import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' declare module '@deepseek-ai/dsh-session/types' { @@ -44,7 +45,7 @@ declare module '@deepseek-ai/dsh-session/types' { * Supporting another composition input is a deliberate version change, never * an implicit extra field. */ -export const SUBAGENT_DESCRIPTOR_VERSION = 2 +export const SUBAGENT_DESCRIPTOR_VERSION = 3 /** Fields shared by every supported `subagent/descriptor` payload. */ interface SubagentDescriptorBase { @@ -76,6 +77,8 @@ export interface ContinuableSubagentDescriptorData extends SubagentDescriptorBas readonly agentProvider?: string /** Resolved child `agentOptions.model`, when one was declared. */ readonly agentModel?: string + /** Resolved child `agentOptions.reasoningEffort`, when one was declared. */ + readonly agentReasoningEffort?: ReasoningEffortId /** Per-child persona that shadows the deployment persona on resume. */ readonly persona?: string /** Child tool scoping reapplied on resume. */ @@ -111,6 +114,8 @@ export interface ContinuableSubagentDescriptorInput extends SubagentDescriptorIn readonly agentProvider?: string /** Requested child `agentOptions.model`. */ readonly agentModel?: string + /** Requested child `agentOptions.reasoningEffort`. */ + readonly agentReasoningEffort?: ReasoningEffortId /** Requested per-child persona. */ readonly persona?: string /** Requested child tool scoping. */ @@ -133,6 +138,7 @@ const CONTINUABLE_DESCRIPTOR_KEYS = new Set([ ...DESCRIPTOR_BASE_KEYS, 'agentProvider', 'agentModel', + 'agentReasoningEffort', 'persona', 'toolFilter', ]) @@ -231,6 +237,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef } const agentProvider = optionalString(value, 'agentProvider') const agentModel = optionalString(value, 'agentModel') + const agentReasoningEffort = optionalString(value, 'agentReasoningEffort') as ReasoningEffortId | undefined const persona = optionalString(value, 'persona') const toolFilter = Object.hasOwn(value, 'toolFilter') ? parseToolFilter(value['toolFilter']) @@ -242,6 +249,7 @@ function parseSubagentDescriptor(value: unknown): SubagentDescriptorData | undef label, ...agentProvider !== undefined ? { agentProvider } : {}, ...agentModel !== undefined ? { agentModel } : {}, + ...agentReasoningEffort !== undefined ? { agentReasoningEffort } : {}, ...persona !== undefined ? { persona } : {}, ...toolFilter !== undefined ? { toolFilter } : {}, } @@ -283,6 +291,7 @@ export function snapshotSubagentDescriptor(input: SubagentDescriptorInput): Suba label: input.label, ...input.agentProvider !== undefined ? { agentProvider: input.agentProvider } : {}, ...input.agentModel !== undefined ? { agentModel: input.agentModel } : {}, + ...input.agentReasoningEffort !== undefined ? { agentReasoningEffort: input.agentReasoningEffort } : {}, ...input.persona !== undefined ? { persona: input.persona } : {}, ...input.toolFilter !== undefined ? { toolFilter: input.toolFilter } : {}, } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 2f29e32010..42dca84908 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -103,6 +103,7 @@ export { applyChildComposition, captureDelegatedPolicyOverrides, childSessionMeta, + parentAgentOptionsForDelegation, resolveChildAgentOptions, resolveChildDepth, SubagentDepthError, @@ -494,6 +495,7 @@ export class SubagentRuntime extends Service { /** Reject the first requested capability that the provider lacks. */ private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void { const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [ + { when: request.agentOptions !== undefined, cap: 'agentOptions' }, { when: request.outputSchema !== undefined, cap: 'outputSchema' }, { when: request.maxDepth !== undefined, cap: 'depthLimit' }, { when: request.toolFilter !== undefined, cap: 'toolFilter' }, diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index abb6dd50e7..2667884af4 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -44,10 +44,11 @@ function limitSubagentDiagnostic(diagnostic: string): string { /** * The capability advertisement of an out-of-process backend: NONE. A child in * another process cannot honor parent-enforced start features - * (`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a + * (`agentOptions`/`outputSchema`/`maxDepth`/`toolFilter`/`persona`), so the service rejects a * request needing any of them before `start` runs — never accepted-then-ignored. */ export const NO_START_CAPABILITIES: SubagentCapabilities = Object.freeze({ + agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 17978550ab..415379ca75 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -84,6 +84,7 @@ export interface SubagentRunEndInfo { * to `maxDepth`; the other names match. */ export interface SubagentCapabilities { + readonly agentOptions: boolean readonly outputSchema: boolean readonly depthLimit: boolean readonly toolFilter: boolean @@ -116,6 +117,12 @@ export interface SubagentStartRequest { * remaining turn work when it fires afterward. */ readonly signal: AbortSignal + /** + * Optional host-Agent provider, model, reasoning-effort, and output-token + * overrides. Requires {@link SubagentCapabilities.agentOptions}; in-process + * providers merge them over the parent Agent's options when they create the + * child. + */ readonly agentOptions?: AgentOptions /** * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects diff --git a/packages/subagent/subagent/tests/child-agent.spec.ts b/packages/subagent/subagent/tests/child-agent.spec.ts new file mode 100644 index 0000000000..92302cfca4 --- /dev/null +++ b/packages/subagent/subagent/tests/child-agent.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { resolveChildAgentOptions } from '../src/child-agent.ts' + +function parentAgent(): Agent { + const id = SessionId('parent') + return { + id, + options: { + provider: 'parent-provider', + model: 'parent-model', + reasoningEffort: ReasoningEffortId('high'), + maxTokens: 512, + }, + session: Session.create(id), + } as Agent +} + +describe('child Agent options', () => { + it('inherits the parent effort while the exact route is unchanged', () => { + expect(resolveChildAgentOptions(parentAgent(), undefined, 1)).toEqual({ + provider: 'parent-provider', + model: 'parent-model', + reasoningEffort: 'high', + maxTokens: 512, + subagentDepth: 1, + }) + }) + + it('clears an inherited effort when the child route changes', () => { + expect(resolveChildAgentOptions(parentAgent(), { model: 'child-model' }, 1)).toEqual({ + provider: 'parent-provider', + model: 'child-model', + maxTokens: 512, + subagentDepth: 1, + }) + }) + + it('keeps an explicit child effort when the child route changes', () => { + expect(resolveChildAgentOptions(parentAgent(), { + provider: 'child-provider', + model: 'child-model', + reasoningEffort: ReasoningEffortId('max'), + }, 1)).toEqual({ + provider: 'child-provider', + model: 'child-model', + reasoningEffort: 'max', + maxTokens: 512, + subagentDepth: 1, + }) + }) + + it('inherits the latest logged request selection over creation-time values', () => { + const parent = parentAgent() + parent.session.append('request/header', { + header: { + config: { + provider: 'current-provider', + model: 'current-model', + reasoningEffort: ReasoningEffortId('low'), + }, + }, + reason: 'initial', + }) + + expect(resolveChildAgentOptions(parent, undefined, 1)).toEqual({ + provider: 'current-provider', + model: 'current-model', + reasoningEffort: 'low', + maxTokens: 512, + subagentDepth: 1, + }) + }) +}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 6cf1aea5a8..d1d40e0ff4 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -12,7 +12,7 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -239,7 +239,7 @@ describe('SubagentRuntime.startContinuable', () => { const start = vi.fn(async () => { throw new Error('must not dispatch') }) ctx.subagents.registerProvider({ name: 'one-shot', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start, }) @@ -283,6 +283,40 @@ describe('SubagentRuntime.startContinuable', () => { expect(loaded.meta.origin).toBe('subagent') }) + it('persists a selected reasoning effort and reapplies it on cold resume', async () => { + const effort = ReasoningEffortId('max') + const adapter = new MockAdapter([ + textResponse('first answer'), + textResponse('resumed answer'), + ], { + efforts: [{ id: effort, name: 'Max' }], + defaultEffort: effort, + }) + const { ctx, parent } = await setupWith(adapter) + parkParent(ctx, parent) + const childEfforts: Array = [] + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) childEfforts.push(agent.options.reasoningEffort) + }) + + const started = await ctx.subagents.startContinuable({ + ...startSpec(parent), + request: { + prompt: message('selected reasoning'), + parent, + agentOptions: { reasoningEffort: effort }, + }, + }) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) + .toMatchObject({ agentReasoningEffort: 'max' }) + + await followup(ctx, parent, started.childId, message('resume selected reasoning')) + await waitNoActivation(ctx, started.childId) + expect(childEfforts).toEqual(['max', 'max']) + }) + it('rolls the child back completely when the caller signal aborts before acceptance', async () => { const { ctx, parent } = await setup([textResponse('unused')]) const controller = new AbortController() @@ -531,7 +565,7 @@ describe('SubagentRuntime.followup residency routing', () => { await ctx.plugin(SubagentInvariant) const disposeProvider = ctx.subagents.registerProvider({ name: 'retired', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('one-shot start is not used') }, prepareContinuable: () => Promise.resolve({}), @@ -2418,27 +2452,43 @@ describe('continuable errors', () => { hold.resolve(undefined) }) - it('reapplies the descriptor model route on cold resume', async () => { - const { ctx, parent } = await setup([textResponse('first'), textResponse('resumed')]) + it('reapplies the descriptor model route and reasoning effort on cold resume', async () => { + const effort = ReasoningEffortId('high') + const adapter = new MockAdapter([textResponse('first'), textResponse('resumed')], { + efforts: [{ id: effort, name: 'High' }], + defaultEffort: effort, + }) + const { ctx, parent } = await setupWith(adapter) const started = await ctx.subagents.startContinuable({ ...startSpec(parent), request: { prompt: message('routed work'), parent, - agentOptions: { provider: 'mock', model: 'child-model' }, + agentOptions: { provider: 'mock', model: 'child-model', reasoningEffort: effort }, }, }) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(loaded.events.find(event => event.type === 'subagent/descriptor')?.data) - .toMatchObject({ agentProvider: 'mock', agentModel: 'child-model' }) + .toMatchObject({ + agentProvider: 'mock', + agentModel: 'child-model', + agentReasoningEffort: 'high', + }) // The resumed Activation runs on the declared route, not the parent's. await followup(ctx, parent, started.childId, message('again')) await vi.waitFor(() => { - expect(ctx.agents.get(started.childId)?.options.model).toBe('child-model') + expect(ctx.agents.get(started.childId)?.options).toMatchObject({ + model: 'child-model', + reasoningEffort: 'high', + }) }) await waitNoActivation(ctx, started.childId) + const resumed = await ctx.sessionPersistence.load(started.childId) + expect(resumed.events.flatMap(event => event.type === 'request/header' + ? [event.data.header.config.reasoningEffort] + : [])).toEqual([effort, effort]) }) it('unloading the manager drains its live activations', async () => { diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index 91200abf8d..08792c2348 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -21,7 +21,7 @@ async function setup(): Promise { const provider = (name: string): SubagentProvider => ({ name, - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('not used') }, }) diff --git a/packages/subagent/subagent/tests/out-of-process.spec.ts b/packages/subagent/subagent/tests/out-of-process.spec.ts index 0d3307ca51..98b670f36f 100644 --- a/packages/subagent/subagent/tests/out-of-process.spec.ts +++ b/packages/subagent/subagent/tests/out-of-process.spec.ts @@ -21,7 +21,13 @@ import { describe('NO_START_CAPABILITIES', () => { it('advertises nothing and is frozen (shared by every out-of-process backend)', () => { - expect(NO_START_CAPABILITIES).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false }) + expect(NO_START_CAPABILITIES).toEqual({ + agentOptions: false, + outputSchema: false, + depthLimit: false, + toolFilter: false, + persona: false, + }) expect(Object.isFrozen(NO_START_CAPABILITIES)).toBe(true) }) }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 05e9785611..6a93f2fdef 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { HarnessError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentRuntime, { foldSubagentDescriptor, @@ -24,8 +24,8 @@ function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent } -const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } -const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false, persona: false } +const ALL_CAPS: SubagentCapabilities = { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true } +const NO_CAPS: SubagentCapabilities = { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false } function baseRequest(overrides: Partial = {}): SubagentStartRequest { return { @@ -162,6 +162,7 @@ describe('SubagentRuntime', () => { }) it.each([ + ['agentOptions', { agentOptions: { model: 'child-model' } }], ['outputSchema', { outputSchema: { type: 'object', properties: {} } }], ['depthLimit', { maxDepth: 1 }], ['toolFilter', { toolFilter: { deny: ['bash'] } }], @@ -348,6 +349,7 @@ describe('subagent descriptors', () => { label: 'complete child', agentProvider: 'deepseek', agentModel: 'chat', + agentReasoningEffort: ReasoningEffortId('high'), persona: 'reviewer', toolFilter: { allow: ['read'], deny: ['bash'] }, } @@ -357,6 +359,7 @@ describe('subagent descriptors', () => { label: complete.label, agentProvider: complete.agentProvider, agentModel: complete.agentModel, + agentReasoningEffort: complete.agentReasoningEffort, persona: complete.persona, toolFilter: complete.toolFilter, })).toEqual(complete) @@ -448,6 +451,13 @@ describe('subagent descriptors', () => { label: 'l', agentModel: [], }, 'agentModel must be a string'], + ['invalid agent reasoning effort', { + version: SUBAGENT_DESCRIPTOR_VERSION, + mode: 'continuable', + provider: 'spawn', + label: 'l', + agentReasoningEffort: 7, + }, 'agentReasoningEffort must be a string'], ['invalid persona', { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 29d65cf9da..f6caeb1941 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: 28e6213b903ffffa7934e244b2a74ada519b32b2 -README.zh.md: 74e8896a152c787abd0aebf055d6e13f6158bcd3 +README.md: e643442de7fa45f15a5c2bf818e2c25feb44b6c5 +README.zh.md: aa6dec73c66ce6b4db525d09cd166e671dbec9dc diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 28e6213b90..e643442de7 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -6,7 +6,11 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C ## Provider selection and lifecycle -Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. +Each plugin instance binds one subagent transport `provider` to one `toolName`; the model cannot change that transport. Load another distinctly named instance to expose another transport. `enableModelSelection: true`, or an enabled Host preference when `modelSelectionSettings: true`, requires that provider's child `agentOptions` capability and exposes optional child LLM `provider`, `model`, and `reasoning_effort` fields without additional route configuration. A call may supply a complete provider/model pair, or only an effort when configured or parent values supply the effective route. The live adapter resolves explicit or configured routes before child creation. A call that omits every selection field uses `agentOptions` and then inherits compatible missing values from the parent's latest logged request selection, falling back to its creation options before the first request and retaining its configured `maxTokens`. Changing provider or model without naming an effort clears the lower layer's route-owned effort so the selected model resolves its default. + +The delegation tool registers only while its subagent provider exists, avoiding sibling load-order and provider-reload dependencies. When model selection is enabled, its optional fields remain visible without `ctx.llm`; a call that selects a route rejects if the service is unavailable. When disabled, the schema omits those fields and execution rejects a forced selection. Configured `agentOptions` remain deployment-owned child defaults independently of this model-facing switch. Adapter catalog and topology changes do not rewrite or re-register the tool. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. + +An enabled definition registers `list_subagent_models`, which lists registered providers, one provider's advertised models, or one exact model's reasoning efforts at call time. At most one instance in a tool scope may enable selection because this discovery tool has a global name; duplicate owners fail registration. Shipped product compositions default the primary `subagent` (`spawn`) instance off and sample the Host `subagent-model-selection.enabled` preference when each new top-level session is composed. The enabled decision is logged as `subagent/model-selection-enabled`, inherited by child sessions, and retained on resume; later settings edits do not change a running session. Shipped compositions deliberately keep `subagent_fork` disabled so the fork inherits the parent's provider and model: changing that route would forfeit provider-side KV Cache reuse of the inherited conversation prefix and can make prefix recomputation dominate the delegated task's cost. This restriction remains even if discovery ownership is separated. Catalog membership remains advisory: an enabled delegation tool accepts an unlisted model id when its adapter does. The [model-selected route Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md) owns the rationale and reintroduction condition. A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text. Abort, refusal, token limit, and other failures become errored tool results whose message contains the stop-reason headline, an optional provider-authored `SubagentResult.diagnostic`, and then any preserved partial assistant text. The diagnostic remains separate from `SubagentResult.output`, so a truncated answer is never reported as success or confused with infrastructure detail. If result collection and disposal both reject, the errored result preserves both failures. @@ -20,9 +24,11 @@ A foreground call passes the execution signal through startup and execution, awa |---|---| | `provider` (required) | Provider name (`spawn`, `fork`, `acp`, ...). | | `toolName` | Model-facing name, default `subagent`; distinct for every loaded instance. | +| `enableModelSelection` | Exposes and accepts model-facing child LLM selection fields and registers the shared `list_subagent_models` tool, default `false`. It requires the subagent provider's `agentOptions` capability. At most one instance in a tool scope may enable it; the discovery schema remains registered without `ctx.llm`, while discovery and selected-route calls reject until that optional service is available. Configured `agentOptions` remain available when this switch is disabled. | +| `modelSelectionSettings` | Samples the Host `subagent-model-selection` preference while composing an Agent, records an enabled decision in its Session, and inherits that decision in child Sessions. Default `false`; mutually exclusive with `enableModelSelection` and valid only in an Agent-scoped composition. The preference defaults off and changes only subsequently composed top-level Sessions. | | `enableRunInBackground` | Exposes background mode, default `true`; disabling also rejects forced background calls. | | `backgroundMode` | Background lifecycle policy, default `one-shot`. `one-shot` defaults calls to foreground; `continuable` defaults them to background, requires the provider's `prepareContinuable` capability, and returns a durable child id without requiring the follow-up tool. | -| `agentOptions` | Provider-specific child `provider`, `model`, and positive `maxTokens`; the in-process provider treats explicit values as overrides of inherited parent options. | +| `agentOptions` | Configured child LLM `provider`, `model`, adapter-owned `reasoningEffort`, and positive `maxTokens`; requires the subagent provider's `agentOptions` capability. In-process providers merge explicit values over the parent's latest logged request selection, or its creation options before the first request. An inherited effort survives only while the effective provider/model route is unchanged; changing the route without an explicit effort lets the selected model supply its default. A configured provider, model, or effort is checked through the optional `ctx.llm` service before child creation even when the call omits model-selection fields; a missing service or invalid value rejects the call. | | `persona` | Per-child persona; requires provider `persona` capability. | | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | | `maxDepth` | Absolute delegation-depth cap, default `3` (`0` forbids delegation); a numeric cap requires the `depthLimit` capability and fails the mount without it. `'provider-managed'` sends no cap for an out-of-process provider whose budget belongs to the child harness. The tool stays visible at the cap; each attempted start checks the calling agent's current depth and returns an errored tool result when rejected. | @@ -37,15 +43,29 @@ Foreground and background calls are concurrency-safe: sibling delegations in one #### What the model sees -The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the job id collected with `job_output` or stopped with `job_kill`. While the tool is visible in an assembly's scope, a `tool:` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. `enableModelSelection` adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance; the provider must support `agentOptions`. Provider context inheritance changes the tool and prompt descriptions. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default, runtime settlement notice, and explicit foreground override, while one-shot mode documents its `false` default and the job id collected with `job_output` or stopped with `job_kill`. While the tool is visible in an assembly's scope, a `tool:` system-prompt section tells the model to start independent continuable delegations together, keep working while they run, and choose foreground only when its next action depends on the result; a tool restriction removes both its schema and this guidance. #### Token effect -Fixed schema cost per parent request; each provider instance adds one schema, and each continuable instance adds one short system-prompt section. +Fixed schema cost per parent request; enabling model selection adds three parameters. Each subagent provider instance adds one schema, and each continuable instance adds one short system-prompt section. #### KV Cache effect -Prefix-stable while provider instances, names, descriptions, and schemas are unchanged. Provider registration lifecycle may invalidate parent reuse from the first changed tool definition. +Prefix-stable while subagent provider instances and their configuration are unchanged. Adapter catalog changes do not alter the definition. A route override on an inheritance-capable instance may prevent the child from reusing the inherited parent prefix. + +### Model selection and discovery + +#### What the model sees + +An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session decision is enabled, exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. With no arguments the discovery tool returns registered provider ids and names; with `provider` it returns that adapter's advertised models; with `provider` and `model` it resolves the exact model and returns its advertised reasoning efforts and default. The result is read-only runtime metadata, not an authorization list. + +#### Token effect + +One fixed tool schema is present in shipped compositions. Directory contents enter the transcript only when the model calls the tool. + +#### KV Cache effect + +The schema is prefix-stable across adapter registration and catalog changes. Each result is appended after the reusable prefix. ### Foreground result @@ -79,4 +99,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Background runs expose no result through this tool** — a one-shot task's final output is collected through the generic task surface, and a continuable child's output stays in its own session, read by its subagent id. The settlement notice states how that child ended and carries any final assistant message, but it is not this call's return value and cannot be awaited here. - **Duplicate names across waiting one-shot instances are detected late** (`TODO(subagent-dup-toolname)`) — continuable instances reserve their prompt-section name during plugin application, but preventing provider-registration rollback for waiting one-shot instances requires a registry of intended names. -- **Child policy is fixed per instance** — another model, persona, tool filter, or depth cap requires another distinctly named tool. +- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable the fields only when route changes preserve reuse or expose a bounded recomputation cost. +- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM provider/model/reasoning-effort selection requires static enablement or an enabled per-Session preference and a subagent provider that advertises `agentOptions`; out-of-process providers currently reject enabling it rather than ignore it. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 74e8896a15..aa6dec73c6 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -6,7 +6,11 @@ ## 提供方选择与生命周期 -每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 +每个插件实例把一个 subagent 传输 `provider` 绑定到一个 `toolName`;模型不能改变该传输。如需公开另一种传输,请加载另一个名称不同的实例。`enableModelSelection: true`,或 `modelSelectionSettings: true` 时已启用的 Host 偏好,都要求该提供方具备子级 `agentOptions` 能力,并且无需额外路由配置即可公开可选的子 agent LLM `provider`、`model` 与 `reasoning_effort` 字段。调用可以提供完整的提供方/模型对;当配置值或父 Agent 值能够提供生效路由时,也可以只提供推理强度。实时 adapter 会在创建子 agent 前解析显式或配置的路由。完全省略选择字段的调用使用 `agentOptions`,再从父 Agent 最新记录的请求选择中继承兼容的缺失值;首个请求之前回退到其创建选项,并保留其中配置的 `maxTokens`。如果更换提供方或模型但没有指定强度,则清除下层路由所属的强度,使所选模型解析自己的默认值。 + +委派工具只在其 subagent 提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。启用模型选择时,即使没有 `ctx.llm`,可选字段仍然可见;选择路由的调用会在该服务缺失时失败。禁用时,schema 会省略这些字段,执行阶段也会拒绝强制传入的选择。配置的 `agentOptions` 仍是部署方所有的子级默认值,不受这个面向模型的开关影响。adapter 目录和拓扑变化不会改写或重新注册工具。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 + +启用的定义会注册 `list_subagent_models`,它会在调用时列出已注册提供方、某个提供方公布的模型,或某个精确模型的推理强度。因为发现工具使用全局名称,一个工具作用域最多只能由一个实例启用选择;多个持有方会使注册失败。随附产品组合默认关闭主 `subagent`(`spawn`)实例,并在每个新的顶层会话完成组合时读取 Host 的 `subagent-model-selection.enabled` 偏好。启用决定记录为 `subagent/model-selection-enabled`,由子会话继承并在恢复时保留;之后修改设置不会改变运行中的会话。组合会刻意在 `subagent_fork` 上保持禁用,使 fork 继承父级的提供方与模型:更改该路由会失去继承对话前缀的提供方侧 KV Cache 复用,重新计算前缀的成本可能超过委派任务本身。即使分离发现工具的持有权,该限制也仍然成立。目录条目仍只提供建议:如果适配器接受未列出的模型 ID,启用选择的委派工具也会接受。理由与重新开放条件由[模型选择路由 Agent Note](../../../.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md)负责。 前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本。中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息依次包含终止原因标题、可选的提供方 `SubagentResult.diagnostic`,以及子 agent 保留下来的部分 assistant 文本。诊断与 `SubagentResult.output` 保持分离,因此被截断的回答不会被报告为成功,也不会与基础设施说明混淆。如果结果收集与 dispose(资源释放)都 reject,出错结果会保留两项失败。 @@ -20,9 +24,11 @@ |---|---| | `provider`(必填) | 提供方名称(`spawn`、`fork`、`acp` 等)。 | | `toolName` | 面向模型的名称,默认 `subagent`;每个已加载实例必须不同。 | +| `enableModelSelection` | 公开并接受面向模型的子级 LLM 选择字段,同时注册共享的 `list_subagent_models` 工具;默认为 `false`。它要求 subagent 提供方具备 `agentOptions` 能力。一个工具作用域最多只能由一个实例启用;即使没有 `ctx.llm`,发现 schema 仍保持注册,而发现调用和所选路由调用会在该可选服务可用前失败。禁用此开关时仍可配置 `agentOptions`。 | +| `modelSelectionSettings` | 组合 Agent 时读取 Host 的 `subagent-model-selection` 偏好,把启用决定记录进其 Session,并让子 Session 继承该决定。默认为 `false`;与 `enableModelSelection` 互斥,且只能用于 Agent 作用域组合。该偏好默认关闭,只影响之后组合的新顶层 Session。 | | `enableRunInBackground` | 公开后台模式,默认 `true`;禁用时也会拒绝强制后台调用。 | | `backgroundMode` | 后台生命周期策略,默认 `one-shot`。`one-shot` 默认前台调用;`continuable` 默认后台调用,要求提供方具备 `prepareContinuable` 能力,并返回持久化子 agent ID,且不要求加载后续消息工具。 | -| `agentOptions` | 传给具体提供方的子 agent `provider`、`model` 和正整数 `maxTokens`;进程内提供方会用显式值覆盖继承的父级选项。 | +| `agentOptions` | 配置的子 agent LLM `provider`、`model`、adapter 自有 `reasoningEffort` 与正整数 `maxTokens`;要求 subagent 提供方具备 `agentOptions` 能力。进程内提供方把显式值合并到父 Agent 最新记录的请求选择之上;首个请求之前则合并到其创建选项之上。只有生效提供方/模型路由不变时才会保留继承的推理强度;改变路由但不显式提供强度时,由所选模型提供默认值。即使调用省略模型选择字段,配置的提供方、模型或强度也会在创建子 agent 前通过可选 `ctx.llm` 服务进行校验;服务缺失或值无效都会拒绝调用。 | | `persona` | 每个子 agent 独立的 persona;要求提供方具备 `persona` 能力。 | | `toolFilter` | 每个子 agent 独立的全局工具限制;要求提供方具备 `toolFilter` 能力。 | | `maxDepth` | 绝对委派深度上限,默认 `3`(`0` 禁止委派);数值上限要求 `depthLimit` 能力,缺失时挂载失败。对于预算由子 harness 拥有的进程外提供方,`'provider-managed'` 不发送上限。工具在达到上限时仍然可见;每次尝试启动都会检查调用 agent 的当前深度,被拒绝时返回出错的工具结果。 | @@ -37,15 +43,29 @@ #### 模型看到的内容 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。`enableModelSelection` 会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引;提供方必须支持 `agentOptions`。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 #### Token 影响 -每个父级请求都会产生固定的 schema token 开销;每个提供方实例增加一个 schema,每个可继续实例还会增加一个简短的系统提示词 section。 +每个父级请求都会产生固定的 schema token 开销;启用模型选择会增加三个参数。每个 subagent 提供方实例增加一个 schema,每个可继续实例还会增加一个简短的系统提示词 section。 #### KV Cache 影响 -只要提供方实例、名称、描述和 schema 不变,前缀就保持稳定。提供方注册生命周期可能从首个变化的工具定义开始,使父级复用失效。 +只要 subagent 提供方实例及其配置不变,前缀就保持稳定。adapter 目录变化不会改变定义。具备继承能力的实例如果覆盖路由,可能阻止子 agent 复用继承的父级前缀。 + +### 模型选择与发现 + +#### 模型看到的内容 + +静态配置 `enableModelSelection: true` 的实例,或 Session 决定为启用的 settings 控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。无参数调用发现工具会返回已注册提供方的 ID 和名称;提供 `provider` 时返回该适配器公布的模型;同时提供 `provider` 和 `model` 时解析精确模型,并返回其公布的推理强度和默认值。结果是只读的运行时元数据,不是授权列表。 + +#### Token 影响 + +随附组合会包含一个固定工具 schema。只有模型调用该工具时,目录内容才会进入 transcript。 + +#### KV Cache 影响 + +adapter 注册和目录变化不会改变 schema 的前缀稳定性。每次结果都追加在可复用前缀之后。 ### 前台结果 @@ -79,4 +99,5 @@ - **后台运行不通过本工具公开结果**:一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。 - **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`):可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。 -- **每个实例的子 agent 策略固定**:其他模型、persona、工具过滤器或深度上限都需要另一个名称不同的工具。 +- **随附 fork 工具无法选择子级 LLM 路由**:它们会继承父级的提供方与模型,使复制的对话前缀仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才重新启用这些字段。 +- **每个实例的非路由子 agent 策略固定**:其他 persona、工具过滤器或深度上限都需要另一个名称不同的工具。LLM 提供方/模型/推理强度选择要求静态启用或每 Session 偏好已启用,并要求 subagent 提供方声明 `agentOptions`;进程外提供方目前会拒绝启用它,而不是忽略它。 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 9ddab59996..708ca2335b 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -18,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./model-selection-settings": { + "types": "./lib/types/model-selection-settings.d.ts", + "default": "./lib/model-selection-settings.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -28,6 +32,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/model-selection-settings.js", "lib/types/**/*.d.ts" ], "license": "MIT", @@ -35,6 +40,9 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", @@ -50,8 +58,10 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index ef0e9941eb..ceb04cced8 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -10,14 +10,33 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' -import { assertSubagentMaxDepth, settleRun } from '@deepseek-ai/dsh-subagent' +import { + assertSubagentMaxDepth, + parentAgentOptionsForDelegation, + settleRun, +} from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' import type { JobOutcome } from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { + hasConfiguredLlmSelection, + hasDelegationModelRequest, + preflightChildLlmRoute, + requestedAgentOptions, +} from './model-selection.ts' +import type { DelegationModelRequest } from './model-selection.ts' +import { registerListSubagentModels } from './list-models.ts' +import type {} from './model-selection-settings.ts' +import { + hasSubagentModelSelection, + recordSubagentModelSelection, +} from './model-selection-state.ts' export const name = 'tool-subagent' export const inject = ['tools', 'subagents', 'systemPrompt'] @@ -34,6 +53,14 @@ export interface Config { * a distinct name. */ toolName?: string + /** Let the model discover and select the child LLM route (default false). */ + enableModelSelection?: boolean + /** + * Sample the Host `subagent-model-selection` user setting for each new + * top-level session and inherit that decision in its child sessions. Mutually + * exclusive with `enableModelSelection`. + */ + modelSelectionSettings?: boolean /** * Expose `run_in_background` (default true). Disabled instances omit the * parameter and reject forced background calls. @@ -81,14 +108,22 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), + enableModelSelection: z.boolean().default(false), + modelSelectionSettings: z.boolean().default(false), enableRunInBackground: z.boolean().default(true), backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'), // Prevent Schemastery from materializing omitted agentOptions as `{}`. agentOptions: z.object({ provider: z.string(), model: z.string(), + reasoningEffort: z.string().min(1) as z>, maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER), - }).default(undefined as unknown as { provider: string; model: string; maxTokens: number }), + }).default(undefined as unknown as { + provider: string + model: string + reasoningEffort: ReturnType + maxTokens: number + }), persona: z.string(), // Preserve omission; Schemastery's `{ allow: [] }` default would deny every tool. toolFilter: z.object({ @@ -281,196 +316,348 @@ export function apply(ctx: Context, config: Config): void { if (config.toolFilter !== undefined && config.toolFilter.allow === undefined && config.toolFilter.deny === undefined) { throw new Error('tool-subagent: `toolFilter` is configured but names neither `allow` nor `deny` — remove the key or fill the filter') } + if (config.enableModelSelection === true && config.modelSelectionSettings === true) { + throw new Error('tool-subagent: `enableModelSelection` and `modelSelectionSettings` are mutually exclusive') + } const backgroundEnabled = config.enableRunInBackground !== false const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' const toolName = config.toolName ?? 'subagent' - // Load order and HMR replacement can change provider availability while - // this fiber remains active. - let disposeTool: (() => void) | undefined - const mount = (provider: SubagentProvider): void => { - // A numeric cap the provider cannot enforce is a misconfiguration — fail at - // mount (the earliest point the provider's capabilities are known), not on - // the first delegation. - if (typeof config.maxDepth === 'number' && !provider.capabilities.depthLimit) { + + const modelSelectionCapable = config.enableModelSelection === true || config.modelSelectionSettings === true + + const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { + if (typeof config.maxDepth === 'number' && !subagentProvider.capabilities.depthLimit) { throw new Error( - `tool-subagent: provider "${provider.name}" cannot enforce maxDepth (no depthLimit capability) — ` + `tool-subagent: provider "${subagentProvider.name}" cannot enforce maxDepth (no depthLimit capability) — ` + 'set maxDepth: \'provider-managed\' to leave the recursion budget to the provider', ) } - const wording = providerWording(provider.inheritsParentContext) - if (continuable && provider.prepareContinuable === undefined) { + if (config.agentOptions !== undefined && !subagentProvider.capabilities.agentOptions) { throw new Error( - `tool-subagent: provider "${provider.name}" does not support \`backgroundMode: continuable\``, + `tool-subagent: provider "${subagentProvider.name}" does not support child agentOptions`, + ) + } + if (modelSelectionCapable && !subagentProvider.capabilities.agentOptions) { + throw new Error( + `tool-subagent: provider "${subagentProvider.name}" does not support child model selection`, + ) + } + if (continuable && subagentProvider.prepareContinuable === undefined) { + throw new Error( + `tool-subagent: provider "${subagentProvider.name}" does not support \`backgroundMode: continuable\``, ) } - disposeTool = ctx.tools.register(defineTool({ - name: toolName, - description: wording.description + (backgroundEnabled - // The completion notice is the continuation service's own behavior, not - // a separately installed capability, so this promise holds whenever the - // continuable background path is reachable at all. - ? continuable - ? ' This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.' - : ' This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.' - : ' This call waits for the subagent and returns its result.'), - parameters: { - description: { - type: 'string', - required: true, - description: 'A short (3-5 word) description of the delegated task, for display.', - }, - prompt: { - type: 'string', - required: true, - description: wording.promptDescription, - }, - ...backgroundEnabled ? { - run_in_background: { - type: 'boolean' as const, - description: continuable - ? 'Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it.' - : 'Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill.', - }, - } : {}, - }, - output: { - schema: { - oneOf: [ - { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'background' }, - jobId: { type: 'string', required: true }, - }, - }, - { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'continuable' }, - subagentId: { type: 'string', required: true }, - }, - }, - { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'foreground' }, - runId: { type: 'string', required: true }, - output: { type: 'array', required: true, items: { type: 'json' } }, - }, - }, - ], - }, - render: (_args, value) => [{ - type: 'text', - text: value.kind === 'background' - ? `started background subagent job ${value.jobId}` - : value.kind === 'continuable' - ? `started subagent ${value.subagentId}` - : outputValueText(value.output), - }], - }, - // Children never mutate the parent session; the one parent-owned write - // (tasks.start) is a synchronous commutative insertion. - isConcurrencySafe: () => true, - async execute(args, exec) { - const parent = exec.agent - if (!parent) { - // Non-agent callers provide no parent for delegation ownership. - throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') - } - - const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined - const request = { - label: args.description, - prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[], - parent, - ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, - ...config.persona !== undefined ? { persona: config.persona } : {}, - ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, - ...maxDepth !== undefined ? { maxDepth } : {}, - } - - const runSpec = resolveDelegationRun(args, { backgroundEnabled, continuable }) - if (runSpec.runInBackground) { - if (continuable) { - // Resolves at inbox acceptance: the child owns its own turns from - // there, so this call neither waits for nor collects a result. - const started = await ctx.subagents.startContinuable({ - provider: config.provider, - label: args.description, - request, - signal: exec.signal, - }) - return { kind: 'continuable' as const, subagentId: started.childId } - } - const jobs = ctx.get('jobs') - if (jobs === undefined) { - throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') - } - // One-shot background child: job preflight finishes before the - // starter can spawn, and the task-owned signal covers startup. - const id = jobs.start({ - kind: 'subagent', - label: args.description, - owner: parent, - run: () => { - const controller = new AbortController() - const start = ctx.subagents.start(config.provider, { ...request, signal: controller.signal }) - return { - cancel: (reason?: string) => { - controller.abort(reason ?? 'background subagent task killed') - }, - done: settleStart(start, controller.signal), - // No readOutput: the child session owns intermediate detail. - } - }, - }) - return { kind: 'background' as const, jobId: id } - } - - const run: SubagentRun = await ctx.subagents.start(config.provider, { - ...request, - signal: exec.signal, - }) - return settleForegroundRun(run) - }, - })) } - // Register listeners before checking presence so no synchronous change is missed. - // TODO(subagent-dup-toolname): two waiting one-shot fibers configured with the - // same toolName collide when their provider appears, and the duplicate-name - // throw rolls back the provider registration. Continuable instances reserve - // their prompt-section name during apply() and fail earlier. Add an intent - // registry if the late one-shot collision occurs in a shipped composition. - ctx.on('subagent/provider-added', (provider) => { - if (provider.name === config.provider && disposeTool === undefined) mount(provider) + // Validate provider-owned config outside the optional LLM binding so an + // invalid provider always rejects its registration or this plugin's load. + ctx.on('subagent/provider-added', (subagentProvider) => { + if (subagentProvider.name === config.provider) assertSubagentProviderConfiguration(subagentProvider) }) - ctx.on('subagent/provider-removed', (name) => { - if (name !== config.provider || disposeTool === undefined) return - disposeTool() - disposeTool = undefined - }) - const present = ctx.subagents.getProvider(config.provider) - if (present !== undefined) { - mount(present) - } else { - // A backend fiber may activate later; a misspelled provider remains visible in this log. - ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) - } - if (backgroundEnabled && continuable) { - // The section follows provider availability without its own manual - // lifecycle: empty text is omitted from rendered prompts while the tool is - // absent, and the registration itself stays owned by this plugin fiber. - ctx.systemPrompt.section({ - name: `tool:${toolName}`, - order: SUBAGENT_SECTION_ORDER, - text: context => disposeTool === undefined || ctx.tools.get(toolName, context.scope) === undefined + const initialProvider = ctx.subagents.getProvider(config.provider) + if (initialProvider !== undefined) assertSubagentProviderConfiguration(initialProvider) + + const install = (runtimeCtx: Context, modelSelectionEnabled: boolean): void => { + if (modelSelectionEnabled) registerListSubagentModels(runtimeCtx) + // Load order and HMR replacement can change provider availability while + // this fiber remains active. + let mounted: { subagentProvider: SubagentProvider; disposeTool: () => void } | undefined + const mount = (subagentProvider: SubagentProvider): void => { + assertSubagentProviderConfiguration(subagentProvider) + const wording = providerWording(subagentProvider.inheritsParentContext) + const choiceDescription = !modelSelectionEnabled ? '' - : `Use ${toolName} in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`, + : ' Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model\'s default effort.' + + (subagentProvider.inheritsParentContext + ? ' Changing the route can prevent provider-side reuse of the inherited conversation prefix.' + : '') + const disposeTool = runtimeCtx.tools.register(defineTool({ + name: toolName, + description: wording.description + (backgroundEnabled + // The completion notice is the continuation service's own behavior, not + // a separately installed capability, so this promise holds whenever the + // continuable background path is reachable at all. + ? continuable + ? ' This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.' + : ' This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.' + : ' This call waits for the subagent and returns its result.') + choiceDescription, + parameters: { + description: { + type: 'string', + required: true, + description: 'A short (3-5 word) description of the delegated task, for display.', + }, + prompt: { + type: 'string', + required: true, + description: wording.promptDescription, + }, + ...modelSelectionEnabled ? { + provider: { + type: 'string' as const, + description: 'LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route.', + }, + model: { + type: 'string' as const, + description: 'Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route.', + }, + reasoning_effort: { + type: 'string' as const, + description: 'Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model\'s default.', + }, + } : {}, + ...backgroundEnabled ? { + run_in_background: { + type: 'boolean' as const, + description: continuable + ? 'Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it.' + : 'Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill.', + }, + } : {}, + }, + output: { + schema: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'background' }, + jobId: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'continuable' }, + subagentId: { type: 'string', required: true }, + }, + }, + { + type: 'object', + additionalProperties: false, + properties: { + kind: { type: 'string', required: true, const: 'foreground' }, + runId: { type: 'string', required: true }, + output: { type: 'array', required: true, items: { type: 'json' } }, + }, + }, + ], + }, + render: (_args, value) => [{ + type: 'text', + text: value.kind === 'background' + ? `started background subagent job ${value.jobId}` + : value.kind === 'continuable' + ? `started subagent ${value.subagentId}` + : outputValueText(value.output), + }], + }, + // Children never mutate the parent session; the one parent-owned write + // (tasks.start) is a synchronous commutative insertion. + isConcurrencySafe: () => true, + async execute(args, exec) { + const parent = exec.agent + if (!parent) { + // Non-agent callers provide no parent for delegation ownership. + throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') + } + + const modelRequest = args as DelegationModelRequest + const parentOptions = parentAgentOptionsForDelegation(parent) + const childAgentOptions = requestedAgentOptions( + parentOptions, + config.agentOptions, + modelRequest, + modelSelectionEnabled, + ) + if (hasDelegationModelRequest(modelRequest) || hasConfiguredLlmSelection(config.agentOptions)) { + const llm = runtimeCtx.get('llm') + if (llm === undefined) { + throw new Error('cannot resolve the selected child LLM route because the `llm` service is unavailable') + } + await preflightChildLlmRoute(llm, parentOptions, childAgentOptions, exec.signal) + } + exec.signal.throwIfAborted() + const maxDepth = typeof config.maxDepth === 'number' ? config.maxDepth : undefined + const request = { + label: args.description, + prompt: [{ type: 'text', text: args.prompt }] as ContentBlock[], + parent, + ...childAgentOptions !== undefined ? { agentOptions: childAgentOptions } : {}, + ...config.persona !== undefined ? { persona: config.persona } : {}, + ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, + ...maxDepth !== undefined ? { maxDepth } : {}, + } + + const runSpec = resolveDelegationRun(args, { backgroundEnabled, continuable }) + if (runSpec.runInBackground) { + if (continuable) { + // Resolves at inbox acceptance: the child owns its own turns from + // there, so this call neither waits for nor collects a result. + const started = await runtimeCtx.subagents.startContinuable({ + provider: config.provider, + label: args.description, + request, + signal: exec.signal, + }) + return { kind: 'continuable' as const, subagentId: started.childId } + } + const jobs = runtimeCtx.get('jobs') + if (jobs === undefined) { + throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs') + } + // One-shot background child: job preflight finishes before the + // starter can spawn, and the task-owned signal covers startup. + const id = jobs.start({ + kind: 'subagent', + label: args.description, + owner: parent, + run: () => { + const controller = new AbortController() + const start = runtimeCtx.subagents.start(config.provider, { ...request, signal: controller.signal }) + return { + cancel: (reason?: string) => { + controller.abort(reason ?? 'background subagent task killed') + }, + done: settleStart(start, controller.signal), + // No readOutput: the child session owns intermediate detail. + } + }, + }) + return { kind: 'background' as const, jobId: id } + } + + const run: SubagentRun = await runtimeCtx.subagents.start(config.provider, { + ...request, + signal: exec.signal, + }) + return settleForegroundRun(run) + }, + })) + mounted = { subagentProvider, disposeTool } + } + + // Register listeners before checking presence so no synchronous change is missed. + // TODO(subagent-dup-toolname): two waiting one-shot fibers configured with the + // same toolName collide when their provider appears, and the duplicate-name + // throw rolls back the provider registration. Continuable instances reserve + // their prompt-section name during apply() and fail earlier. Add an intent + // registry if the late one-shot collision occurs in a shipped composition. + runtimeCtx.on('subagent/provider-added', (subagentProvider) => { + if (subagentProvider.name === config.provider && mounted === undefined) mount(subagentProvider) + }) + runtimeCtx.on('subagent/provider-removed', (name) => { + if (name !== config.provider || mounted === undefined) return + mounted.disposeTool() + mounted = undefined + }) + const present = runtimeCtx.subagents.getProvider(config.provider) + if (present !== undefined) { + mount(present) + } else { + // A backend fiber may activate later; a misspelled provider remains visible in this log. + runtimeCtx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) + } + if (backgroundEnabled && continuable) { + // The section follows provider availability without its own manual + // lifecycle: empty text is omitted from rendered prompts while the tool is + // absent, and the registration itself stays owned by this plugin fiber. + runtimeCtx.systemPrompt.section({ + name: `tool:${toolName}`, + order: SUBAGENT_SECTION_ORDER, + text: context => mounted === undefined || runtimeCtx.tools.get(toolName, context.scope) === undefined + ? '' + : `Use ${toolName} in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set \`run_in_background: false\` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.`, + }) + } + } + + if (config.modelSelectionSettings !== true) { + install(ctx, config.enableModelSelection === true) + return + } + + const settings = ctx.get('subagentModelSelection') + if (settings === undefined) { + throw new Error( + 'tool-subagent: `modelSelectionSettings` requires ' + + '@deepseek-ai/dsh-tool-subagent/model-selection-settings in the Host scope', + ) + } + const compositionScope = scopeOf(ctx) + if (compositionScope === undefined) { + throw new Error('tool-subagent: `modelSelectionSettings` requires an Agent or preset scope') + } + + const selectForAgent = (agent: NonNullable): boolean => { + let enabled = hasSubagentModelSelection(agent.session) + if (!enabled) { + const parentId = agent.session.header.origin === 'subagent' + ? agent.session.header.parentSession + : undefined + if (parentId !== undefined) { + const parent = ctx.get('agents')?.get(parentId) + enabled = parent !== undefined && hasSubagentModelSelection(parent.session) + } else if (agent.session.firstLiveSeq === 0) { + enabled = settings.currentEnabled() + } + } + if (enabled) recordSubagentModelSelection(agent.session) + return enabled + } + + const agent = ctx.agent + if (agent !== undefined) { + install(ctx, selectForAgent(agent)) + return + } + const agents = ctx.get('agents') + /* v8 ignore next -- Agent and preset scopes are minted only by the Agent registry. */ + if (agents === undefined) throw new Error('tool-subagent: scoped model-selection settings require the Agent registry') + const scopedInstalls = new WeakMap>() + const installing = new WeakSet() + const belongsToComposition = (candidate: Agent): boolean => + scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope) + const installScoped = (candidate: Agent): void => { + if (scopedInstalls.has(candidate) || installing.has(candidate)) return + // Reserve before the injected fiber runs: tool registration emits + // `tools/change` synchronously, which re-enters the reconciliation below. + installing.add(candidate) + const enabled = selectForAgent(candidate) + const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { + install(runtimeCtx, enabled) + }) + installing.delete(candidate) + scopedInstalls.set(candidate, fiber) + } + const removeScoped = (candidate: Agent): void => { + const fiber = scopedInstalls.get(candidate) + if (fiber === undefined) return + scopedInstalls.delete(candidate) + /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */ + void fiber.dispose().catch((error: unknown) => { + ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`) }) } + const reconcileComposedAgents = (): void => { + // Every Agent and preset scope is minted by the Agent registry; the scope + // check above makes this same-process typed relationship authoritative. + for (const candidate of agents.list()) { + if (belongsToComposition(candidate)) installScoped(candidate) + else removeScoped(candidate) + } + } + // A shipped preset is mounted once in a standing scope. Its listener admits + // only descendant Agents and installs the sampled tool definition in each + // Agent's own scope, so a later settings change cannot mutate a live session. + ctx.on('agent/created', ({ agent: created }) => { + installScoped(created) + }) + ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) }) + // Reparenting an Agent between standing presets changes its inherited tool + // set and emits `tools/change`; reconcile the Agent-owned override with the + // new ancestry. Other registry changes are idempotent no-ops here. + ctx.on('tools/change', reconcileComposedAgents) } diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 5b8facc900..84bd209caa 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -5,7 +5,8 @@ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { hasSubagentModelSelection } from './model-selection-state.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -14,11 +15,24 @@ export const name = 'tool-subagent-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -const install: InvariantInstaller = () => {} +/** Assert that a durable opt-in is represented by both model-facing definitions. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + ctx.on('agent/pre-step', async ({ agent }, next) => { + if (hasSubagentModelSelection(agent.session)) { + const schemas = ctx.tools.schemas(agent) + const selectable = schemas.some((schema) => { + const properties = (schema.parameters as { properties?: Record }).properties + return properties?.['provider'] !== undefined + && properties['model'] !== undefined + && properties['reasoning_effort'] !== undefined + }) + if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) { + fail('a subagent/model-selection-enabled session must expose route fields and list_subagent_models') + } + } + return next() + }, { global: true }) +}, { inject: ['tools'] }) /** * Register this package's invariant companion. diff --git a/packages/subagent/tool-subagent/src/list-models.ts b/packages/subagent/tool-subagent/src/list-models.ts new file mode 100644 index 0000000000..9e1ff5c24e --- /dev/null +++ b/packages/subagent/tool-subagent/src/list-models.ts @@ -0,0 +1,94 @@ +/** Model-facing discovery of LLM routes available to child Agents. */ + +import type { Context } from '@deepseek-ai/cordis' +import type LlmRuntime from '@deepseek-ai/dsh-llm' +import type { LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' + +interface ListSubagentModelsRequest { + readonly provider?: string + readonly model?: string +} + +/** Resolve one registered provider with a model-correctable diagnostic. */ +function registeredProvider(llm: LlmRuntime, providerId: string): LlmProviderInfo { + const providers = llm.listProviders() + const provider = providers.find(candidate => candidate.id === providerId) + if (provider !== undefined) return provider + const available = providers.map(candidate => candidate.id).join(', ') || '(none)' + throw new Error(`LLM provider "${providerId}" is not registered; available providers: ${available}`) +} + +/** Render one advertised or resolved model. */ +function modelLine(provider: string, model: { id: string; name: string; description?: string }): string { + return `${provider}/${model.id} — ${model.name}${model.description === undefined ? '' : `: ${model.description}`}` +} + +/** Read the requested provider, advertised models, or exact-model efforts. */ +async function listSubagentModels( + ctx: Context, + request: ListSubagentModelsRequest, + signal: AbortSignal, +): Promise { + const llm = ctx.get('llm') + if (llm === undefined) { + throw new Error('cannot discover child LLM routes because the `llm` service is unavailable') + } + if (request.model !== undefined && request.provider === undefined) { + throw new Error('`model` requires `provider`') + } + if (request.provider === undefined) { + const providers = llm.listProviders() + return providers.length === 0 + ? '(no LLM providers)' + : providers.map(provider => `${provider.id} — ${provider.name}`).join('\n') + } + if (request.provider.length === 0) throw new Error('`provider` must be non-empty') + const provider = registeredProvider(llm, request.provider) + if (request.model === undefined) { + const models = await llm.listModels(provider.id) + return models.length === 0 + ? `(no advertised models for ${provider.id})` + : models.map(model => modelLine(provider.id, model)).join('\n') + } + if (request.model.length === 0) throw new Error('`model` must be non-empty') + const model = await llm.resolveModelInfo(provider.id, request.model, signal) + const efforts = model.reasoning?.efforts.map(effort => ( + `${effort.id}${model.reasoning?.defaultEffort === effort.id ? ' (default)' : ''} — ${effort.name}` + + (effort.description === undefined ? '' : `: ${effort.description}`) + )).join('\n') || '(no advertised reasoning efforts)' + return `${modelLine(provider.id, model)}\nReasoning efforts:\n${efforts}` +} + +/** + * Register `list_subagent_models` for one owning delegation-tool instance. + * @param ctx - Context whose tool registry owns the fixed discovery definition. + */ +export function registerListSubagentModels(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'list_subagent_models', + description: + 'Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list ' + + 'registered providers, with `provider` to list its advertised models, or with `provider` and `model` ' + + 'to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may ' + + 'accept an unlisted model id. Use the returned ids with a delegation tool\'s `provider`, `model`, and ' + + '`reasoning_effort` fields.', + parameters: { + provider: { + type: 'string', + description: 'Registered LLM provider id. Omit to list providers.', + }, + model: { + type: 'string', + description: 'Exact model id to inspect. Requires provider; omit to list that provider\'s advertised models.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, result) => [{ type: 'text', text: result }], + }, + execute(args, exec) { + return listSubagentModels(ctx, args, exec.signal) + }, + })) +} diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts new file mode 100644 index 0000000000..113cd4c6f8 --- /dev/null +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -0,0 +1,70 @@ +/** Host-owned opt-in setting for model-selectable subagent delegation. */ + +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** User preference sampled when a new Agent receives its delegation tools. */ + subagentModelSelection: SubagentModelSelectionConfig + } +} + +/** User-settings section for model-selectable subagent delegation. */ +export const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = settingsNamespace('subagent-model-selection') + +/** Stored user preference; the shipped composition defaults it off. */ +export interface SubagentModelSelectionSettings { + /** Whether new Agents may expose child LLM route selection to the model. */ + enabled: boolean +} + +/** Schema served to settings clients for the opt-in preference. */ +export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z = z.object({ + enabled: z.boolean().default(false), +}) + +/** Optional deployment base for the preference. */ +export interface Config { + /** Initial value inherited when the user document does not override it. */ + enabled?: boolean +} + +/** Singleton settings owner read by delegation tools when an Agent is published. */ +export class SubagentModelSelectionConfig extends Service { + static Config: z = z.object({ + enabled: z.boolean().default(false), + }) + + private source: () => SubagentModelSelectionSettings + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'subagentModelSelection') + const entry: SubagentModelSelectionSettings = { enabled: config.enabled === true } + this.source = () => entry + installSettingsSection( + ctx, + SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, + SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA, + entry, + { + setSource: (source) => { this.source = source }, + // Consumers sample at Agent publication, so a settings update never + // rebuilds the tool definitions of an Agent that is already running. + onChange: () => {}, + }, + ) + } + + /** + * Read the preference for the next eligible Agent publication. + * @returns whether that Agent should receive model-selectable delegation. + */ + currentEnabled(): boolean { + return this.source().enabled + } +} + +export const name = 'subagent-model-selection-settings' +export default SubagentModelSelectionConfig diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts new file mode 100644 index 0000000000..35345ac115 --- /dev/null +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -0,0 +1,33 @@ +/** Durable per-session state for the user-controlled model-selection opt-in. */ + +import type { Session } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Records that this session's delegation tool exposes child provider, + * model, and reasoning-effort selection. Appended before the first model + * request; absence means the fixed-route definition. Log-only: it carries + * no `surfaceOp` and never enters model history. + */ + 'subagent/model-selection-enabled': Record + } +} + +/** + * Whether a session log records the enabled model-selection definition. + * @param session - session whose durable decision is read. + * @returns whether model-selectable delegation is enabled for the session. + */ +export function hasSubagentModelSelection(session: Session): boolean { + return session.events.some(event => event.type === 'subagent/model-selection-enabled') +} + +/** + * Append the enabled decision once, before its definition can reach a model request. + * @param session - session receiving the enabled decision. + */ +export function recordSubagentModelSelection(session: Session): void { + if (hasSubagentModelSelection(session)) return + session.append('subagent/model-selection-enabled', {}) +} diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts new file mode 100644 index 0000000000..6b89d92742 --- /dev/null +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -0,0 +1,112 @@ +/** Child LLM route selection for the subagent tool. */ + +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { LlmRuntime } from '@deepseek-ai/dsh-llm' +import type { AgentOptions } from '@deepseek-ai/dsh-agent' + +/** Model-facing child LLM route fields. */ +export interface DelegationModelRequest { + readonly provider?: string + readonly model?: string + readonly reasoning_effort?: string +} + +/** + * Whether a call explicitly selects any child LLM value. + * @param request - Model-facing route fields from the tool call. + * @returns Whether at least one route or effort field is present. + */ +export function hasDelegationModelRequest(request: DelegationModelRequest): boolean { + return request.provider !== undefined + || request.model !== undefined + || request.reasoning_effort !== undefined +} + +/** Reject an empty model-facing route value at the tool JSON boundary. */ +function assertNonEmpty(value: string | undefined, field: keyof DelegationModelRequest): void { + if (value !== undefined && value.length === 0) { + throw new Error(`child LLM \`${field}\` must be non-empty`) + } +} + +/** + * Merge model-supplied selection fields over configured child defaults. + * Provider and model form one route and must be supplied together. Changing + * that route without an effort clears the configured route-owned effort. + * @param parentOptions - Current parent values that supply missing child values. + * @param configured - Tool-instance child defaults. + * @param request - Model-facing route override. + * @param enabled - Whether this tool instance permits model-facing selection. + * @returns Child Agent options, preserving omission when no layer contributes one. + */ +export function requestedAgentOptions( + parentOptions: AgentOptions, + configured: AgentOptions | undefined, + request: DelegationModelRequest, + enabled: boolean, +): AgentOptions | undefined { + if (!hasDelegationModelRequest(request)) return configured + if (!enabled) { + throw new Error('child model selection is disabled for this tool instance') + } + assertNonEmpty(request.provider, 'provider') + assertNonEmpty(request.model, 'model') + assertNonEmpty(request.reasoning_effort, 'reasoning_effort') + if ((request.provider === undefined) !== (request.model === undefined)) { + throw new Error('child LLM `provider` and `model` must be supplied together') + } + + const baselineProvider = configured?.provider ?? parentOptions.provider + const baselineModel = configured?.model ?? parentOptions.model + const routeChanged = request.provider !== undefined + && (request.provider !== baselineProvider || request.model !== baselineModel) + const { reasoningEffort: _configuredReasoningEffort, ...configuredWithoutReasoning } = configured ?? {} + return { + ...routeChanged && request.reasoning_effort === undefined ? configuredWithoutReasoning : configured, + ...request.provider === undefined ? {} : { provider: request.provider, model: request.model }, + ...request.reasoning_effort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(request.reasoning_effort) }, + } +} + +/** + * Whether configured Agent options require route validation before delegation. + * @param options - Tool-instance child defaults. + * @returns Whether configured provider, model, or effort values must be resolved. + */ +export function hasConfiguredLlmSelection(options: AgentOptions | undefined): boolean { + return options?.provider !== undefined + || options?.model !== undefined + || options?.reasoningEffort !== undefined +} + +/** + * Resolve an effective child route through its live adapter before the child is + * created. The LLM runtime owns provider lookup, exact-model metadata, effort + * validation, and adapter defaults. + * @param llm - Live LLM runtime. + * @param parentOptions - Current parent values whose compatible fields the child inherits. + * @param requested - Per-child options after request/config merging. + * @param signal - Tool-call cancellation signal. + */ +export async function preflightChildLlmRoute( + llm: LlmRuntime, + parentOptions: AgentOptions, + requested: AgentOptions | undefined, + signal: AbortSignal, +): Promise { + const provider = requested?.provider ?? parentOptions.provider + const model = requested?.model ?? parentOptions.model + if (provider === undefined || model === undefined) { + throw new Error('cannot select child LLM values without an effective provider and model') + } + const routeChanged = provider !== parentOptions.provider || model !== parentOptions.model + const reasoningEffort = requested?.reasoningEffort + ?? (routeChanged ? undefined : parentOptions.reasoningEffort) + await llm.resolveCallConfig({ + provider, + model, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + }, signal) +} diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts new file mode 100644 index 0000000000..36ac602c89 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -0,0 +1,57 @@ +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import * as mock from './scripted-provider.ts' +import * as tool from '../src/index.ts' + +/** Shared non-aborted tool signal for package-local integration tests. */ +export const testToolSignal = new AbortController().signal + +/** Build the minimal parent Agent owned by the package-local scripted provider. */ +export function fakeAgent(id = 'parent-1'): Agent { + const sessionId = SessionId(id) + return { id: sessionId, options: {}, session: Session.create(sessionId) } as unknown as Agent +} + +/** Mount the real tool and service stack around one scripted subagent provider. */ +export async function setup(toolConfig: tool.Config, mockConfig: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) + await ctx.plugin(tool, toolConfig) + return ctx +} + +let callCounter = 0 + +/** Execute the registered subagent tool through the real ToolRuntime pipeline. */ +export function callSubagent( + ctx: Context, + args: unknown, + over: { agent?: Agent | undefined; signal?: AbortSignal } = {}, +) { + // Distinguish "no override" (use a default agent) from an explicit + // `{ agent: undefined }` (test the no-agent path). Under + // exactOptionalPropertyTypes the key is omitted rather than set to undefined. + const agent = 'agent' in over ? over.agent : fakeAgent() + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`call-${++callCounter}`), + name: 'subagent', + arguments: args, + ...agent ? { agent } : {}, + ...over.signal ? { signal: over.signal } : {}, + }) +} + +/** Join text blocks from one rendered tool result. */ +export function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts new file mode 100644 index 0000000000..de3643fd97 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { + CallId, + LlmAdapter, + ReasoningEffortId, +} from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import * as tool from '../src/index.ts' +import { testToolSignal, text } from './harness.ts' + +class CatalogAdapter extends LlmAdapter { + constructor(private readonly empty = false) { + super() + } + + override providerInfo(provider: string) { + return { id: provider, name: `${provider.toUpperCase()} API` } + } + + override listModels(provider: string): Promise { + if (this.empty) return Promise.resolve([]) + return Promise.resolve([ + { provider, id: 'fast', name: 'Fast', description: 'Focused work.' }, + { provider, id: 'plain', name: 'Plain' }, + ]) + } + + override resolveModel(provider: string, model: string): Promise { + if (model === 'plain') return Promise.resolve({ provider, id: model, name: 'Plain' }) + return Promise.resolve({ + provider, + id: model, + name: 'Fast', + description: 'Focused work.', + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'Quality first.' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + } + + stream(_options: GenerateOptions): AsyncIterable { + return (async function* () { yield { type: 'finish' as const, reason: { kind: 'stop' as const } } })() + } +} + +async function setupListTool() { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + const fiber = await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) + return { ctx, fiber } +} + +let counter = 0 + +function call(ctx: Context, args: unknown) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`list-models-${++counter}`), + name: 'list_subagent_models', + arguments: args, + }) +} + +describe('list_subagent_models', () => { + it('is omitted unless its delegation-tool instance owns discovery', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(tool, { provider: 'unused' }) + expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + }) + + it('stays registered without the optional LLM service and rejects discovery calls', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) + const result = await call(ctx, {}) + expect(result.isError).toBe(true) + expect(text(result)).toContain('`llm` service is unavailable') + }) + + it('rejects two discovery-owning instances in one tool scope', async () => { + const { ctx } = await setupListTool() + await expect(ctx.plugin(tool, { + provider: 'another-unused', + toolName: 'subagent_other', + enableModelSelection: true, + }).then(() => undefined)).rejects.toThrow('tool "list_subagent_models" is already registered') + }) + + it('lists registered providers and follows live registration changes', async () => { + const { ctx, fiber } = await setupListTool() + const empty = await call(ctx, {}) + expect(empty.isError).toBe(false) + expect(text(empty)).toBe('(no LLM providers)') + + const registration = ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const providers = await call(ctx, {}) + expect(providers.isError).toBe(false) + expect(text(providers)).toBe('alpha — ALPHA API') + + registration.replace(['beta']) + const changed = await call(ctx, {}) + expect(text(changed)).toBe('beta — BETA API') + + await fiber.dispose() + expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + }) + + it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('alpha/fast — Fast: Focused work.\nalpha/plain — Plain') + }) + + it('renders an empty advertised model list', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true)) + const result = await call(ctx, { provider: 'alpha' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('(no advertised models for alpha)') + }) + + it('inspects exact-model efforts, descriptions, and defaults', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha', model: 'fast' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe( + 'alpha/fast — Fast: Focused work.\nReasoning efforts:\n' + + 'low — Low\nhigh (default) — High: Quality first.', + ) + }) + + it('renders exact models without reasoning metadata', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha', model: 'plain' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('alpha/plain — Plain\nReasoning efforts:\n(no advertised reasoning efforts)') + }) + + it.each([ + { args: { model: 'fast' }, expected: '`model` requires `provider`' }, + { args: { provider: '' }, expected: '`provider` must be non-empty' }, + { args: { provider: 'missing' }, expected: 'available providers: (none)' }, + ])('rejects incomplete or unavailable provider requests', async ({ args, expected }) => { + const { ctx } = await setupListTool() + const result = await call(ctx, args) + expect(result.isError).toBe(true) + expect(text(result)).toContain(expected) + }) + + it('rejects an empty exact model after resolving the provider', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'alpha', model: '' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('`model` must be non-empty') + }) + + it('reports registered alternatives for an unavailable provider', async () => { + const { ctx } = await setupListTool() + ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const result = await call(ctx, { provider: 'missing' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('available providers: alpha') + }) +}) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts new file mode 100644 index 0000000000..4f95db088f --- /dev/null +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -0,0 +1,248 @@ +/** Default-off settings and per-session model-selection decisions. */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' +import { SettingsProvider } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' +import * as tool from '../src/index.ts' +import * as ToolInvariant from '../src/invariant.ts' +import SubagentModelSelectionConfig, { + SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, +} from '../src/model-selection-settings.ts' +import { hasSubagentModelSelection } from '../src/model-selection-state.ts' + +/** Writable in-memory settings provider for the package integration. */ +class MemorySettings extends SettingsProvider { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +/** Read whether one Agent's delegation definition contains route fields. */ +function selectable(ctx: Context, agent: Awaited>['agent']): boolean { + const schema = ctx.tools.schemas(agent).find(candidate => candidate.name === 'subagent') + const properties = (schema?.parameters as { properties?: Record } | undefined)?.properties + return properties?.['provider'] !== undefined + && properties['model'] !== undefined + && properties['reasoning_effort'] !== undefined + && ctx.tools.schemas(agent).some(candidate => candidate.name === 'list_subagent_models') +} + +/** Mount the real settings, Agent, provider, and tool services. */ +async function boot(): Promise { + const ctx = new Context() + await ctx.plugin(MemorySettings) + await ctx.plugin(SubagentModelSelectionConfig) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentRuntime) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + return ctx +} + +/** Create one Agent whose setup mounts the settings-controlled tool preset row. */ +async function createAgent(ctx: Context, id: string, options: { + meta?: { parentSession: SessionId; origin: 'subagent' } + seed?: readonly SessionEvent[] +} = {}) { + const handle = await ctx.agents.create({ + sessionId: SessionId(id), + ...options, + setup: async (agentCtx) => { + await agentCtx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + }, + }) + return handle.agent +} + +describe('SubagentModelSelectionConfig', () => { + it('uses the composed default without a settings provider', async () => { + const ctx = new Context() + await ctx.plugin(SubagentModelSelectionConfig, { enabled: true }) + + expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + await ctx.fiber.dispose() + }) + + it('defaults off and follows the validated user layer', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings) + await ctx.plugin(SubagentModelSelectionConfig) + + expect(ctx.subagentModelSelection.currentEnabled()).toBe(false) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + await ctx.fiber.dispose() + }) + + it('samples each new root session without changing existing Agents', async () => { + const ctx = await boot() + const disabled = await createAgent(ctx, 'disabled') + expect(selectable(ctx, disabled)).toBe(false) + expect(hasSubagentModelSelection(disabled.session)).toBe(false) + + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await createAgent(ctx, 'enabled') + expect(hasSubagentModelSelection(enabled.session)).toBe(true) + expect(selectable(ctx, enabled)).toBe(true) + expect(selectable(ctx, disabled)).toBe(false) + + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const disabledAgain = await createAgent(ctx, 'disabled-again') + expect(selectable(ctx, disabledAgain)).toBe(false) + expect(selectable(ctx, enabled)).toBe(true) + await ctx.fiber.dispose() + }) + + it('installs per-Agent definitions for a shared preset scope', async () => { + const ctx = await boot() + const preset = createScope(ctx, { preset: 'standard' }) + const other = createScope(ctx, { preset: 'minimal' }) + await preset.ctx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + + let enabledBinding: ReturnType | undefined + const createComposed = async (id: string) => ctx.agents.create({ + sessionId: SessionId(id), + setup: (agentCtx) => { + const binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) + if (id === 'preset-enabled') enabledBinding = binding + }, + }) + + const disabled = await createComposed('preset-disabled') + expect(selectable(ctx, disabled.agent)).toBe(false) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await createComposed('preset-enabled') + expect(selectable(ctx, enabled.agent)).toBe(true) + expect(selectable(ctx, disabled.agent)).toBe(false) + + enabledBinding!.rebind(scopeOf(other.ctx)!) + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(false) }) + enabledBinding!.rebind(scopeOf(preset.ctx)!) + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(true) }) + + await enabled.dispose() + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await disabled.dispose() + await ctx.fiber.dispose() + }) + + it('inherits the parent decision and preserves seeded decisions across composition', async () => { + const ctx = await boot() + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const parent = await createAgent(ctx, 'parent') + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const child = await createAgent(ctx, 'child', { + meta: { parentSession: parent.id, origin: 'subagent' }, + }) + expect(selectable(ctx, child)).toBe(true) + expect(hasSubagentModelSelection(child.session)).toBe(true) + + const enabledSeed = Session.create(SessionId('enabled-seed')) + enabledSeed.append('subagent/model-selection-enabled', {}) + const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events }) + expect(selectable(ctx, resumedEnabled)).toBe(true) + + const oldSeed = Session.create(SessionId('old-seed'), []) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) + expect(selectable(ctx, resumedDisabled)).toBe(false) + expect(hasSubagentModelSelection(resumedDisabled.session)).toBe(false) + await ctx.fiber.dispose() + }) + + it('rejects ambiguous static and settings-controlled configuration', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SubagentRuntime) + expect(() => { + tool.apply(ctx, { + provider: 'missing', + enableModelSelection: true, + modelSelectionSettings: true, + }) + }).toThrow('mutually exclusive') + await ctx.fiber.dispose() + }) + + it('requires both the Host setting owner and a composition scope', async () => { + const withoutSettings = new Context() + await mountAgentLoopTestDependencies(withoutSettings) + await withoutSettings.plugin(SubagentRuntime) + expect(() => { + tool.apply(withoutSettings, { + provider: 'missing', + modelSelectionSettings: true, + maxDepth: 'provider-managed', + }) + }).toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings') + await withoutSettings.fiber.dispose() + + const withoutAgent = await boot() + expect(() => { + tool.apply(withoutAgent, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + }).toThrow('requires an Agent or preset scope') + await withoutAgent.fiber.dispose() + }) + + it('checks the durable decision against the published tool definitions', async () => { + const ctx = await boot() + await ctx.plugin(InvariantRegistry, { enabled: true }) + await ctx.plugin(ToolInvariant) + const disabled = await createAgent(ctx, 'invariant-disabled') + const next = () => Promise.resolve({ kind: 'enter' as const, messages: [] }) + const payload = { + agent: disabled, + messages: [], + turn: 1, + step: 1, + signal: new AbortController().signal, + } + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)).resolves.toEqual({ + kind: 'enter', messages: [], + }) + + disabled.session.append('subagent/model-selection-enabled', {}) + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) + .rejects.toThrow('must expose route fields and list_subagent_models') + + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + const enabled = await createAgent(ctx, 'invariant-enabled') + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) + .resolves.toEqual({ kind: 'enter', messages: [] }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts new file mode 100644 index 0000000000..008ba66da4 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as mock from './scripted-provider.ts' +import * as tool from '../src/index.ts' +import { callSubagent, setup, text } from './harness.ts' + +const REASONING = { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), +} as const + +function parentWithRoute( + options: Agent['options'] = { + provider: 'alpha', + model: 'parent-model', + reasoningEffort: ReasoningEffortId('high'), + }, +): Agent { + const id = SessionId('parent-with-route') + return { id, options, session: Session.create(id) } as unknown as Agent +} + +describe('dsh-tool-subagent model selection', () => { + it('exposes static route fields and discovery when selection is enabled', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + const props = (schema.parameters as { properties?: Record }).properties ?? {} + expect(Object.keys(props).sort()).toEqual([ + 'description', + 'model', + 'prompt', + 'provider', + 'reasoning_effort', + 'run_in_background', + ]) + expect(schema.description).toContain('list_subagent_models') + expect(ctx.tools.get('list_subagent_models')).toBeDefined() + expect(schema.description).not.toContain('alpha') + + const registration = ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) + const definition = ctx.tools.get('subagent') + registration.replace(['beta']) + expect(ctx.tools.get('subagent')).toBe(definition) + expect(definition?.description).not.toContain('beta') + }) + + it('hides and rejects route fields when selection is disabled', async () => { + const ctx = await setup({ provider: 'mock' }) + const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + const props = (schema.parameters as { properties?: Record }).properties ?? {} + expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background']) + expect(schema.description).not.toContain('list_subagent_models') + expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + + const result = await callSubagent(ctx, { + description: 'forced route', + prompt: 'do it', + provider: 'alpha', + model: 'fast-model', + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('child model selection is disabled for this tool instance') + }) + + it('rejects enabled model selection when the provider cannot apply Agent options', async () => { + await expect(setup( + { provider: 'mock', enableModelSelection: true, maxDepth: 'provider-managed' }, + { capabilities: { agentOptions: false } }, + )).rejects.toThrow('provider "mock" does not support child model selection') + }) + + it('selects an unlisted complete route and clears a configured effort when the route changes', async () => { + const requests: SubagentStartRequest[] = [] + const ctx = await setup({ + provider: 'mock', + enableModelSelection: true, + agentOptions: { + provider: 'alpha', + model: 'configured-model', + reasoningEffort: ReasoningEffortId('high'), + maxTokens: 321, + }, + }, { onStart: (request) => { requests.push(request) } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + + const selected = await callSubagent(ctx, { + description: 'route work', + prompt: 'do it', + provider: 'alpha', + model: 'unlisted-model', + }) + expect(selected.isError).toBe(false) + expect(requests[0]?.agentOptions).toEqual({ + provider: 'alpha', + model: 'unlisted-model', + maxTokens: 321, + }) + + const effort = await callSubagent(ctx, { + description: 'same route effort', + prompt: 'do it', + provider: 'alpha', + model: 'configured-model', + reasoning_effort: 'low', + }) + expect(effort.isError).toBe(false) + expect(requests[1]?.agentOptions).toEqual({ + provider: 'alpha', + model: 'configured-model', + reasoningEffort: 'low', + maxTokens: 321, + }) + }) + + it('accepts an effort-only override for the effective configured or parent route', async () => { + const requests: SubagentStartRequest[] = [] + const ctx = await setup({ + provider: 'mock', + enableModelSelection: true, + agentOptions: { provider: 'alpha' }, + }, { onStart: (request) => { requests.push(request) } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + + const result = await callSubagent(ctx, { + description: 'effort work', + prompt: 'do it', + reasoning_effort: 'low', + }, { agent: parentWithRoute() }) + expect(result.isError).toBe(false) + expect(requests[0]?.agentOptions).toEqual({ provider: 'alpha', reasoningEffort: 'low' }) + + const inherited = await setup({ provider: 'mock', enableModelSelection: true }) + inherited.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const inheritedResult = await callSubagent(inherited, { + description: 'parent effort work', + prompt: 'do it', + reasoning_effort: 'low', + }, { agent: parentWithRoute() }) + expect(inheritedResult.isError).toBe(false) + }) + + it('inherits a parent effort only when an explicit route stays unchanged', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const result = await callSubagent(ctx, { + description: 'same route work', + prompt: 'do it', + provider: 'alpha', + model: 'parent-model', + }, { agent: parentWithRoute() }) + expect(result.isError).toBe(false) + }) + + it('compares explicit routes with the latest logged parent selection', async () => { + const requests: SubagentStartRequest[] = [] + const ctx = await setup({ + provider: 'mock', + enableModelSelection: true, + agentOptions: { reasoningEffort: ReasoningEffortId('high') }, + }, { onStart: (request) => { requests.push(request) } }) + ctx.llm.registerAdapter(['current-provider'], new MockAdapter([], REASONING)) + const parent = parentWithRoute({ provider: 'created-provider', model: 'created-model' }) + parent.session.append('request/header', { + header: { config: { provider: 'current-provider', model: 'current-model' } }, + reason: 'initial', + }) + + const result = await callSubagent(ctx, { + description: 'same current route', + prompt: 'do it', + provider: 'current-provider', + model: 'current-model', + }, { agent: parent }) + + expect(result.isError).toBe(false) + expect(requests[0]?.agentOptions).toEqual({ + provider: 'current-provider', + model: 'current-model', + reasoningEffort: 'high', + }) + }) + + it('rejects an effort without any effective route', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const result = await callSubagent(ctx, { + description: 'missing route', + prompt: 'do it', + reasoning_effort: 'low', + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('without an effective provider and model') + }) + + it.each([ + { provider: 'alpha' }, + { model: 'fast-model' }, + ])('rejects a partial model-facing route before child creation', async (route) => { + let starts = 0 + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const result = await callSubagent(ctx, { description: 'partial route', prompt: 'do it', ...route }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('`provider` and `model` must be supplied together') + expect(starts).toBe(0) + }) + + it.each([ + { provider: '', model: 'fast-model', expected: '`provider` must be non-empty' }, + { provider: 'alpha', model: '', expected: '`model` must be non-empty' }, + { reasoning_effort: '', expected: '`reasoning_effort` must be non-empty' }, + ])('rejects empty model-facing values', async ({ expected, ...selection }) => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const result = await callSubagent(ctx, { description: 'empty route', prompt: 'do it', ...selection }) + expect(result.isError).toBe(true) + expect(text(result)).toContain(expected) + }) + + it('uses the LLM runtime for provider and reasoning-effort validation before child creation', async () => { + let starts = 0 + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + + const unsupported = await callSubagent(ctx, { + description: 'bad effort', + prompt: 'do it', + provider: 'alpha', + model: 'fast-model', + reasoning_effort: 'max', + }) + expect(unsupported.isError).toBe(true) + expect(text(unsupported)).toContain('does not support reasoning effort "max"') + + const missing = await callSubagent(ctx, { + description: 'bad provider', + prompt: 'do it', + provider: 'missing', + model: 'fast-model', + }) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain('no adapter registered for provider "missing"') + expect(starts).toBe(0) + }) + + it('validates a configured effort before child creation', async () => { + let starts = 0 + const ctx = await setup({ + provider: 'mock', + agentOptions: { + provider: 'alpha', + model: 'parent-model', + reasoningEffort: ReasoningEffortId('high'), + }, + }, { onStart: () => { starts += 1 } }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([], { + efforts: [{ id: ReasoningEffortId('low'), name: 'Low' }], + defaultEffort: ReasoningEffortId('low'), + })) + + const result = await callSubagent( + ctx, + { description: 'same route', prompt: 'do it' }, + { agent: parentWithRoute() }, + ) + expect(result.isError).toBe(true) + expect(text(result)).toContain('does not support reasoning effort "high"') + expect(starts).toBe(0) + }) + + it('validates a configured route before child creation', async () => { + let starts = 0 + const ctx = await setup({ + provider: 'mock', + agentOptions: { provider: 'missing', model: 'configured-model' }, + }, { onStart: () => { starts += 1 } }) + + const result = await callSubagent( + ctx, + { description: 'configured route', prompt: 'do it' }, + { agent: parentWithRoute() }, + ) + + expect(result.isError).toBe(true) + expect(text(result)).toContain('no adapter registered for provider "missing"') + expect(starts).toBe(0) + }) + + it('rejects selected routes or configured efforts when the LLM service is absent', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock' }) + await ctx.plugin(tool, { + provider: 'mock', + enableModelSelection: true, + agentOptions: { + provider: 'alpha', + model: 'fast-model', + reasoningEffort: ReasoningEffortId('high'), + }, + }) + + const configured = await callSubagent(ctx, { description: 'configured effort', prompt: 'do it' }) + expect(configured.isError).toBe(true) + expect(text(configured)).toContain('`llm` service is unavailable') + + const selected = await callSubagent(ctx, { + description: 'selected route', + prompt: 'do it', + provider: 'alpha', + model: 'other-model', + }) + expect(selected.isError).toBe(true) + expect(text(selected)).toContain('`llm` service is unavailable') + }) + + it('keeps pure inherited routing usable without an LLM service lookup', async () => { + let starts = 0 + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock', onStart: () => { starts += 1 } }) + await ctx.plugin(tool, { provider: 'mock' }) + + const result = await callSubagent(ctx, { description: 'inherit route', prompt: 'do it' }) + expect(result.isError).toBe(false) + expect(starts).toBe(1) + }) + + it('warns that changing a fork route can lose inherited-prefix reuse', async () => { + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { inheritsParentContext: true }) + const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + expect(schema.description).toContain('inherits this conversation') + expect(schema.description).toContain('can prevent provider-side reuse of the inherited conversation prefix') + }) + + it('propagates an exact-route resolver failure before child creation', async () => { + let starts = 0 + const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const adapter = new MockAdapter([]) + vi.spyOn(adapter, 'resolveModel').mockRejectedValue(new Error('selected route unavailable')) + ctx.llm.registerAdapter(['alpha'], adapter) + + const result = await callSubagent(ctx, { + description: 'route work', + prompt: 'do it', + provider: 'alpha', + model: 'fast-model', + }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('selected route unavailable') + expect(starts).toBe(0) + }) +}) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index c0da403cd4..a946c6f6fe 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -13,6 +13,7 @@ import type { } from '@deepseek-ai/dsh-subagent' const DEFAULT_CAPABILITIES: SubagentCapabilities = { + agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1ee5e40228..334b721461 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import { CallId } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' @@ -20,9 +20,8 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' -import { SessionId } from '@deepseek-ai/dsh-session' - -const testToolSignal = new AbortController().signal +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { callSubagent, fakeAgent, setup, testToolSignal, text } from './harness.ts' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real @@ -32,40 +31,6 @@ const testToolSignal = new AbortController().signal * shipping code path. */ -/** A minimal parent Agent passed through to the provider request. */ -function fakeAgent(id = 'parent-1'): Agent { - return { id: SessionId(id) } as unknown as Agent -} - -async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) - await ctx.plugin(tool, toolConfig) - return ctx -} - -let callCounter = 0 -function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undefined; signal?: AbortSignal } = {}) { - // Distinguish "no override" (use a default agent) from an explicit - // `{ agent: undefined }` (test the no-agent path). Under - // exactOptionalPropertyTypes the key is omitted rather than set to undefined. - const agent = 'agent' in over ? over.agent : fakeAgent() - return ctx.tools.execute({ - signal: testToolSignal, - callId: CallId(`call-${++callCounter}`), - name: 'subagent', - arguments: args, - ...agent ? { agent } : {}, - ...over.signal ? { signal: over.signal } : {}, - }) -} - -function text(result: { content: { type: string; text?: string }[] }): string { - return result.content.filter(b => b.type === 'text').map(b => b.text).join('') -} describe('dsh-tool-subagent', () => { it('rejects continuable background policy when the provider cannot prepare continuable children', async () => { @@ -83,6 +48,13 @@ describe('dsh-tool-subagent', () => { ) }) + it('rejects configured child agent options at mount when the provider cannot apply them', async () => { + await expect(setup( + { provider: 'mock', maxDepth: 'provider-managed', agentOptions: { model: 'configured-model' } }, + { capabilities: { agentOptions: false } }, + )).rejects.toThrow('does not support child agentOptions') + }) + it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => { const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) const result = await callSubagent(ctx, { @@ -100,20 +72,14 @@ describe('dsh-tool-subagent', () => { expect(text(result)).toBe('child says hi') }) - it('exposes description + prompt + run_in_background to the model (no provider/type parameter)', async () => { - const ctx = await setup({ provider: 'mock' }) - const schema = ctx.tools.schemas().find(s => s.name === 'subagent') - expect(schema).toBeDefined() - const props = (schema!.parameters as { properties?: Record }).properties ?? {} - expect(Object.keys(props).sort()).toEqual(['description', 'prompt', 'run_in_background']) - expect(schema!.description).toContain('job_output') - }) - it('omits run_in_background entirely when the instance disables it (schema and capability never disagree)', async () => { const ctx = await setup({ provider: 'mock', enableRunInBackground: false }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent') const props = (schema!.parameters as { properties?: Record }).properties ?? {} - expect(Object.keys(props).sort()).toEqual(['description', 'prompt']) + expect(Object.keys(props).sort()).toEqual([ + 'description', + 'prompt', + ]) expect(schema!.description).not.toContain('job_output') }) @@ -121,7 +87,13 @@ describe('dsh-tool-subagent', () => { // Schema omission is advertising, not enforcement: the arg validator // allows undeclared keys, so the opt-out must also hold in execute(). const ctx = await setup({ provider: 'mock', enableRunInBackground: false }) - const parent = { id: SessionId('sess-off'), inject: () => {}, options: {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent + const parentId = SessionId('sess-off') + const parent = { + id: parentId, + inject: () => {}, + options: {}, + session: Session.create(parentId), + } as unknown as Agent const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent }) expect(forced.isError).toBe(true) @@ -232,7 +204,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'weird', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('weird-child'), @@ -253,12 +225,13 @@ describe('dsh-tool-subagent', () => { // the request lets us assert the agentOptions reached it. let seen: { agentOptions?: { model?: string } } | undefined const ctx = new Context() + await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -270,10 +243,15 @@ describe('dsh-tool-subagent', () => { } }, }) - await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model' }, maxDepth: 'provider-managed' }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) + await ctx.plugin(tool, { + provider: 'capture', + agentOptions: { provider: 'alpha', model: 'child-model' }, + maxDepth: 'provider-managed', + }) await callSubagent(ctx, { description: 'd', prompt: 'p' }) - expect(seen?.agentOptions).toEqual({ model: 'child-model' }) + expect(seen?.agentOptions).toEqual({ provider: 'alpha', model: 'child-model' }) }) it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { @@ -288,7 +266,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'bare', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -378,7 +356,7 @@ describe('dsh-tool-subagent', () => { // the provider survives. ctx.subagents.registerProvider({ name: 'continuable', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('lifecycle test does not start a child') }, prepareContinuable: async () => ({}), @@ -429,10 +407,14 @@ describe('dsh-tool-subagent', () => { }) it('derives inherited-context wording from a seeded-conversation provider', async () => { - const ctx = await setup({ provider: 'mock', toolName: 'subagent' }, { inheritsParentContext: true }) + const ctx = await setup({ + provider: 'mock', + toolName: 'subagent', + }, { inheritsParentContext: true }) const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! expect(schema.description).toContain('inherits this conversation') expect(schema.description).not.toContain('does not see this conversation') + expect(schema.description).not.toContain('can prevent provider-side reuse of the inherited conversation prefix') const props = (schema.parameters as { properties: Record }).properties expect(props['prompt']!.description).toContain('completed turns') }) @@ -447,7 +429,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -470,7 +452,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -494,7 +476,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -522,7 +504,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), @@ -549,7 +531,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { if (request.signal.aborted) throw new Error('start aborted') @@ -588,7 +570,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { if (request.signal.aborted) sawAborted() @@ -652,7 +634,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture2', - capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: async (request) => { seen = request @@ -709,7 +691,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture3', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -739,7 +721,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture4', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { seen = request @@ -764,7 +746,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'p', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: true, persona: false }, inheritsParentContext: false, start: () => { throw new Error('unreachable') }, }) @@ -783,7 +765,7 @@ describe('dsh-tool-subagent background mode', () => { ctx: scopeFiber.ctx, inject, options: {}, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: Session.create(id), } as unknown as Agent ctx.agents.register(agent) return agent @@ -803,7 +785,7 @@ describe('dsh-tool-subagent background mode', () => { let prepareCalls = 0 ctx.subagents.registerProvider({ name: 'resumable', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async request => ({ id: SessionId('one-shot-child'), @@ -839,7 +821,7 @@ describe('dsh-tool-subagent background mode', () => { }) it('returns a job id immediately and the answer is collected through job_output', async () => { - const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } }, { reply: 'background answer' }) + const ctx = await backgroundSetup({ provider: 'mock' }, { reply: 'background answer' }) const parent = ownerAgent(ctx, 'sess-parent') const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent }) @@ -919,12 +901,41 @@ describe('dsh-tool-subagent background mode', () => { expect(text(result)).toBe('Error: tool call aborted before dispatch') }) + it('skips background startup when cancellation wins asynchronous route preflight', async () => { + const ctx = await backgroundSetup({ provider: 'mock', enableModelSelection: true }) + const parent = ownerAgent(ctx, 'sess-parent') + const adapter = new MockAdapter([]) + let releasePreflight!: () => void + const preflightGate = new Promise((resolve) => { releasePreflight = resolve }) + const resolveModel = vi.spyOn(adapter, 'resolveModel').mockImplementation(async (provider, model) => { + await preflightGate + return { provider, id: model, name: model } + }) + ctx.llm.registerAdapter(['alpha'], adapter) + const controller = new AbortController() + + const resultPromise = callSubagent(ctx, { + description: 'cancelled selection', + prompt: 'do it', + provider: 'alpha', + model: 'selected-model', + run_in_background: true, + }, { agent: parent, signal: controller.signal }) + await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() }) + controller.abort() + releasePreflight() + const result = await resultPromise + + expect(result.isError).toBe(true) + expect(ctx.jobs.list(parent)).toEqual([]) + }) + it('settles an asynchronous provider-start failure as a failed task', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) const parent = ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'broken-start', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('setup failed') }, }) @@ -953,7 +964,7 @@ describe('dsh-tool-subagent background mode', () => { const parent = ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'pending-start', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: request => new Promise((_resolve, reject) => { request.signal.addEventListener('abort', () => { reject(new Error('startup aborted')) }, { once: true }) @@ -990,7 +1001,7 @@ describe('dsh-tool-subagent background mode', () => { const parent = ownerAgent(ctx, 'sess-parent') ctx.subagents.registerProvider({ name: 'broken-start-rollback', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: request => new Promise((_resolve, reject) => { request.signal.addEventListener('abort', () => { @@ -1035,7 +1046,7 @@ describe('dsh-tool-subagent background mode', () => { let starts = 0 ctx.subagents.registerProvider({ name: 'hanging', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void @@ -1182,7 +1193,7 @@ describe('dsh-tool-subagent continuable background mode', () => { let survivingChildId: ReturnType | undefined ctx.subagents.registerProvider({ name: 'gated', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: false, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: async () => { throw new Error('continuable policy must not start a one-shot child') }, prepareContinuable: async (request) => { @@ -1250,14 +1261,14 @@ describe('background preflight failure (no orphaned child, by construction)', () ctx: scopeFiber.ctx, inject: () => {}, options: {}, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: Session.create(id), } as unknown as Agent ctx.agents.register(parent) let starts = 0 ctx.subagents.registerProvider({ name: 'probe', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { starts += 1 @@ -1295,7 +1306,7 @@ describe('depth budget configuration', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: false, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: async (request) => { requests.push(request) @@ -1333,7 +1344,7 @@ describe('depth budget configuration', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'no-depth', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => { throw new Error('unreachable') }, }) @@ -1349,7 +1360,7 @@ describe('depth budget configuration', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'external', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async (request) => { requests.push(request) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 8ee77dfb3b..57ee1dc9d6 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -20,6 +20,15 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, + { + "path": "../../core/scope" + }, + { + "path": "../../settings/settings" + }, { "path": "../../llm/llm" }, diff --git a/packages/subagent/tool-subagent/tsdown.config.ts b/packages/subagent/tool-subagent/tsdown.config.ts new file mode 100644 index 0000000000..d91febcc25 --- /dev/null +++ b/packages/subagent/tool-subagent/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown' + +const entry = (path: string) => ({ + entry: [path], + outDir: 'lib', + format: ['esm'] as const, + platform: 'node' as const, + target: 'es2024' as const, + fixedExtension: false, + dts: false, + clean: false, +}) + +/** Build self-contained Loader entries so the package needs no private chunks. */ +export default defineConfig([ + entry('lib/types/index.js'), + entry('lib/types/model-selection-settings.js'), + entry('lib/types/invariant.js'), +]) diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index 7c6f02e36c..0ccbbd16c6 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -56,6 +56,7 @@ class StubProvider implements SubagentProvider { constructor(options?: { outputSchema?: boolean; inheritsParentContext?: boolean }) { this.capabilities = { + agentOptions: true, outputSchema: options?.outputSchema ?? true, depthLimit: true, toolFilter: true, diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 938f9a5502..f89083faba 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -423,7 +423,7 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spawn', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('the parked-script fixture must not start a child')), }) diff --git a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts index 31998a22cf..cf7439e8eb 100644 --- a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts @@ -30,7 +30,7 @@ await ctx.plugin(SubagentRuntime) let selectedStarts = 0 ctx.subagents.registerProvider({ name: 'built-selected', - capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, async start() { selectedStarts += 1 diff --git a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts index 8d33e373c7..77b87b59f8 100644 --- a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts @@ -21,7 +21,7 @@ it('runs the default config through the source worker', async () => { const subagents = await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'spawn', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('source-worker compat script must not start a child')), } diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index 5130071362..935dbae3fb 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -55,7 +55,13 @@ interface ControlledRun { * the request signal fires, like the real in-process backends. */ class StubProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: false } + readonly capabilities: SubagentCapabilities = { + agentOptions: true, + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: false, + } readonly inheritsParentContext = false readonly runs: ControlledRun[] = [] @@ -459,7 +465,7 @@ describe('dsh-workflow-worker-thread', () => { await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'rejecting', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('reject-child'), @@ -518,7 +524,7 @@ describe('dsh-workflow-worker-thread', () => { await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'bad-dispose', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('bad-dispose-child'), @@ -540,7 +546,7 @@ describe('dsh-workflow-worker-thread', () => { await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'coercion-trap-dispose', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ id: SessionId('trap-child'), @@ -891,7 +897,7 @@ describe('dsh-workflow-worker-thread', () => { const aborted: string[] = [] const provider: SubagentProvider = { name: 'signal-only', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async (request) => { let settle!: (result: SubagentResult) => void @@ -1189,7 +1195,7 @@ describe('dsh-workflow-worker-thread', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger) const provider: SubagentProvider = { name: 'late-ready', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: (request) => { requested.resolve(request) @@ -1250,7 +1256,7 @@ describe('dsh-workflow-worker-thread', () => { const signalAborts: unknown[] = [] const provider: SubagentProvider = { name: 'doomed', - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async (request) => { request.signal.addEventListener('abort', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6b3e12d45..f6878a775b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1808,6 +1808,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../subagent/tool-subagent '@deepseek-ai/dsh-web-frontend': specifier: workspace:^ version: link:../../../apps/web @@ -8563,6 +8566,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -8572,6 +8578,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 35e6eb1049..cd9620e0fa 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -153,6 +153,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], // The shipped preset compositions travel inside the roster package. '@deepseek-ai/dsh-agent-presets': ['presets'], + // The Web Host mounts the default-off settings owner independently of each + // Agent-scoped delegation-tool instance. + '@deepseek-ai/dsh-tool-subagent': ['lib/model-selection-settings.js'], // The argv-prefix runner entry ships beside the lib as its own bundle; // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index db52420840..05a4c8947c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -100,6 +100,7 @@ export const SERVICE_PAGE: Record = { spillStore: 'spill.md', storage: 'storage.md', storageDomain: 'storage.md', + subagentModelSelection: 'subagent.md', subagents: 'subagent.md', subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c5452d8d4a..a47f94b943 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -205,6 +205,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['llm-deepseek', 'llm-pi-ai', 'apiproxy'], note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer.', }, + { + key: 'subagentModelSelection', + pkg: 'tool-subagent', + title: 'Subagent model-selection preference', + mode: 'core', + consumers: ['tool-subagent'], + note: 'Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session.', + }, { key: 'credentials', pkg: 'credentials', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 19ab10c455..3d69da2cc3 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -9,6 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import LlmRuntime from '@deepseek-ai/dsh-llm' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -104,7 +105,7 @@ const OUT = 'docs/tool-catalog.md' function registerCatalogSubagentProvider(ctx: Context, name: string): void { const provider: SubagentProvider = { name, - capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, inheritsParentContext: false, start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), // Declared so consumers configured for continuable background mode mount. @@ -455,17 +456,21 @@ const TOOL_PACKAGES: ToolPackage[] = [ { pkg: '@deepseek-ai/dsh-tool-subagent', dir: 'tool-subagent', - source: 'packages/subagent/tool-subagent/src/index.ts', - requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt'], + source: { + list_subagent_models: 'packages/subagent/tool-subagent/src/list-models.ts', + subagent: 'packages/subagent/tool-subagent/src/index.ts', + }, + requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt', 'ctx.llm for model discovery and selected-route validation'], writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'], shippedNames: ['subagent', 'subagent_fork'], async mount(ctx) { await ctx.plugin(SubagentRuntime) + await ctx.plugin(LlmRuntime) registerCatalogSubagentProvider(ctx, 'mock') - await ctx.plugin(ToolSubagent, { provider: 'mock' }) + await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance\'s description, `run_in_background` parameter, and system-prompt policy follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable` and defaults omitted calls to background with automatic settlement delivery, while `subagent_fork` stays `one-shot` and defaults them to foreground — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 20aecae839..c9dc0735ea 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -2988,7 +2988,7 @@ "seq": 6, "time": 0, "data": { - "version": 2, + "version": 3, "mode": "one-shot", "provider": "spawn", "label": "Check direct child" @@ -3110,11 +3110,10 @@ "config": { "provider": "deepseek-official", "model": "smoke-model", - "maxTokens": 256000, - "reasoningEffort": "high" + "reasoningEffort": "high", + "maxTokens": 256000 }, "adapterDefaults": { - "reasoningEffort": true, "maxTokens": true }, "system": "{{system}}", @@ -3763,7 +3762,7 @@ "seq": 6, "time": 0, "data": { - "version": 2, + "version": 3, "mode": "one-shot", "provider": "spawn" } @@ -3884,11 +3883,10 @@ "config": { "provider": "deepseek-official", "model": "smoke-model", - "maxTokens": 256000, - "reasoningEffort": "high" + "reasoningEffort": "high", + "maxTokens": 256000 }, "adapterDefaults": { - "reasoningEffort": true, "maxTokens": true }, "system": "{{system}}", diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index db7a626752..39ac30a965 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -5,12 +5,12 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index eed0d049f5..7593678f03 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -5,12 +5,12 @@ {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\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":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"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":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":["cordis_define","cordis_inspect_list","cordis_inspect_query","cordis_inspect_self","cordis_run","cordis_stop","cordis_undefine","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","throughSeq":12}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2c64da3ce6..8a8bc37baa 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -108,6 +108,7 @@ "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], + "@deepseek-ai/dsh-tool-subagent/model-selection-settings": ["./packages/subagent/tool-subagent/src/model-selection-settings.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-questions/types": ["./packages/interaction/user-questions/src/types.ts"], "@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"], From ca0b21661effed66f71b044f64e1e697d406db1f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:39:18 +0800 Subject: [PATCH 37/42] feat(python-runtime): package the Windows x64 dsh executable Add node24-win-x64 as the only supported Windows runtime target and publish it as a py3-none-win_amd64 wheel containing the conventional dsh and ripgrep .exe payload names. Keep Windows ARM64 rejected explicitly so Python cannot claim a carrier that CI and release automation do not build. Teach the pkg builder to require a native x64 Windows host, validate both node-pty ConPTY addons, copy @vscode's win32 ripgrep executable, and recognize pkg's .exe output. Extend runtime resolution, wheel staging, payload validation, and the preset closure check so the Windows-specific PowerShell plugins and sidecars fail loud when omitted. The sidecar resolver now maps a packaged main.exe to main-rg.exe; focused TypeScript and Python tests cover that name, the win_amd64 manifest, x64-only host selection, complete wheel payload, ConPTY inventory, and platform-conditioned plugin closure. --- packages/fs/tool-fs-search/src/search-core.ts | 7 +- .../tool-fs-search/tests/rg-sidecar.spec.ts | 29 +++++- pnpm-lock.yaml | 6 ++ python/sdk-runtime/hatch_build.py | 19 +++- python/sdk-runtime/package.json | 2 + python/sdk-runtime/platforms.json | 4 + .../src/deepseek_harness_runtime/__init__.py | 22 +++-- python/sdk/src/deepseek_harness/client.py | 19 +++- python/sdk/tests/test_client.py | 37 ++++++++ python/sdk/tests/test_release_version.py | 19 +++- python/sdk/tests/test_runtime_resolution.py | 24 +++++ ...uild-exe-for-python-sdk-native-pty.spec.ts | 20 +++- .../build-exe-for-python-sdk-native-pty.ts | 22 +++++ scripts/build-exe-for-python-sdk.spec.ts | 81 ++++++++++++++++ scripts/build-exe-for-python-sdk.ts | 92 +++++++++++++++---- scripts/build-python-release.py | 18 ++-- scripts/verify-runtime-closure.spec.ts | 11 ++- scripts/verify-runtime-closure.ts | 1 + 18 files changed, 380 insertions(+), 53 deletions(-) create mode 100644 scripts/build-exe-for-python-sdk.spec.ts diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 5ac5521033..60ea042d4f 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -20,7 +20,7 @@ */ import { existsSync } from 'node:fs' -import { isAbsolute, relative, sep } from 'node:path' +import { isAbsolute, join, parse, relative, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-output-retention' @@ -170,7 +170,10 @@ let rgPathPromise: Promise | undefined */ export function resolveRgPath(): Promise { rgPathPromise ??= Promise.resolve().then(async () => { - const executableSidecar = `${process.execPath}-rg` + const executable = parse(process.execPath) + const executableSidecar = process.platform === 'win32' + ? join(executable.dir, `${executable.name}-rg.exe`) + : `${process.execPath}-rg` if ('pkg' in process && existsSync(executableSidecar)) return executableSidecar return (await import('@vscode/ripgrep')).rgPath }) diff --git a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts index 53c7a01aea..d3a6b5e188 100644 --- a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts +++ b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts @@ -1,9 +1,12 @@ +import { join, parse } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { dependencyRgPath, existsSync } = vi.hoisted(() => ({ dependencyRgPath: '/node_modules/@vscode/ripgrep/bin/rg', existsSync: vi.fn(), })) +const originalPlatform = process.platform +const originalExecPath = process.execPath vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -16,17 +19,35 @@ beforeEach(() => { vi.resetModules() existsSync.mockReset() Reflect.deleteProperty(process, 'pkg') + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform }) + process.execPath = originalExecPath }) afterEach(() => { Reflect.deleteProperty(process, 'pkg') + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: originalPlatform }) + process.execPath = originalExecPath }) describe('ripgrep resolution', () => { it('uses the native sidecar beside the current executable', async () => { Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'linux' }) + process.execPath = '/runtime/dsh' existsSync.mockReturnValue(true) - const sidecar = `${process.execPath}-rg` + const sidecar = '/runtime/dsh-rg' + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') + + await expect(resolveRgPath()).resolves.toBe(sidecar) + expect(existsSync).toHaveBeenCalledWith(sidecar) + }) + + it('uses a conventional executable name for the Windows ripgrep sidecar', async () => { + Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + Reflect.defineProperty(process, 'platform', { configurable: true, enumerable: true, value: 'win32' }) + process.execPath = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64.exe' + existsSync.mockReturnValue(true) + const sidecar = 'C:\\runtime\\deepseek-harness-sdk-runtime-win-x64-rg.exe' const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(sidecar) @@ -47,6 +68,10 @@ describe('ripgrep resolution', () => { const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(dependencyRgPath) - expect(existsSync).toHaveBeenCalledWith(`${process.execPath}-rg`) + const executable = parse(process.execPath) + const sidecar = process.platform === 'win32' + ? join(executable.dir, `${executable.name}-rg.exe`) + : `${process.execPath}-rg` + expect(existsSync).toHaveBeenCalledWith(sidecar) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6878a775b..f7f70486da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9972,6 +9972,12 @@ importers: '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../packages/jobs/tool-jobs + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index c4083387a6..22d0457d86 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -39,7 +39,15 @@ def _host_platform_tag() -> str: machine = platform.machine().lower() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine system = platform.system().lower() - key = f"macos-{arch}" if system == "darwin" else f"linux-{arch}" if system == "linux" else system + key = ( + f"macos-{arch}" + if system == "darwin" + else f"linux-{arch}" + if system == "linux" + else f"win-{arch}" + if system == "windows" + else system + ) try: return _PLATFORMS[key][0] except KeyError as exc: @@ -69,16 +77,21 @@ class RuntimeBuildHook(BuildHookInterface): runtime_files = sorted( runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] ) - expected_files = [expected_executable, f"{expected_executable}-rg"] + expected_files = ( + [expected_executable, f"{expected_executable.removesuffix('.exe')}-rg.exe"] + if expected_executable.endswith(".exe") + else [expected_executable, f"{expected_executable}-rg"] + ) if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") + expected_files.sort() found_files = [path.name for path in runtime_files] if found_files != expected_files: raise RuntimeError( f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) for executable in runtime_files: - if executable.stat().st_mode & stat.S_IXUSR == 0: + if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5684867e6..331d388a0f 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", @@ -103,6 +104,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", diff --git a/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json index e65cd6a735..9c0a1fec72 100644 --- a/python/sdk-runtime/platforms.json +++ b/python/sdk-runtime/platforms.json @@ -10,5 +10,9 @@ "macos-arm64": { "tag": "macosx_14_0_arm64", "executable": "deepseek-harness-sdk-runtime-macos-arm64" + }, + "win-x64": { + "tag": "win_amd64", + "executable": "deepseek-harness-sdk-runtime-win-x64.exe" } } diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 0fc4f416c0..4834a029d1 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -4,9 +4,10 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's ``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): - **exe (production)**: single-file Node executables named - ``deepseek-harness-sdk-runtime--`` (platform in {linux, macos}, arch in - {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling - ``-spawn-helper``. The target machine needs no Node installation. + ``deepseek-harness-sdk-runtime--`` for Linux/macOS and an + ``.exe`` counterpart for Windows. Each has a sibling ripgrep executable; + macOS also uses a sibling ``-spawn-helper``. The target machine needs no + Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a @@ -30,7 +31,7 @@ PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" -_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos"} +_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos", "win32": "win"} _ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"} _EXE_ACQUISITION_HINT = ( @@ -62,13 +63,18 @@ def bundled_runtime_path() -> Path: touching callers). """ tag = _current_platform_tag() - path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}" + extension = ".exe" if tag.startswith("win-") else "" + path = bundled_package_dir() / "runtime" / f"deepseek-harness-sdk-runtime-{tag}{extension}" if not path.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) - ripgrep = Path(f"{path}-rg") + ripgrep = ( + path.with_name(f"{path.stem}-rg.exe") + if tag.startswith("win-") + else Path(f"{path}-rg") + ) if not ripgrep.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the ripgrep sidecar at {ripgrep}. " @@ -110,11 +116,11 @@ def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: def _current_platform_tag() -> str: plat = _PLATFORM_TAGS.get(sys.platform) arch = _ARCH_TAGS.get(platform.machine().lower()) - if plat is None or arch is None: + if plat is None or arch is None or (plat == "win" and arch != "x64"): raise FileNotFoundError( "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " - "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT + "Linux x64/arm64, macOS arm64, and Windows x64. " + _EXE_ACQUISITION_HINT ) return f"{plat}-{arch}" diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 5978d849ab..f6752a9906 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -93,11 +93,14 @@ class HarnessClient: self._start_stderr_thread() def close(self) -> None: + """Close the runtime after a bounded opportunity to flush durable state.""" proc = self._proc if proc is None: return + shutdown_completed = False try: self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) + shutdown_completed = True except Exception as exc: self._stderr_lines.append(f"shutdown request failed: {exc}") if proc.stdin: @@ -105,16 +108,22 @@ class HarnessClient: proc.stdin.close() except Exception as exc: self._stderr_lines.append(f"stdin close failed: {exc}") + if shutdown_completed: + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + pass if proc.poll() is None: try: proc.terminate() except ProcessLookupError: pass - try: - proc.wait(timeout=self.config.shutdown_timeout_seconds) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() + if proc.poll() is None: + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() self._proc = None self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed")) if self._reader_thread and self._reader_thread.is_alive(): diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 1ad89ffd4b..fba315320b 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -783,6 +783,43 @@ for line in sys.stdin: assert client._proc is None +def test_client_close_allows_eof_quiescence_after_shutdown_response(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + marker = tmp_path / "quiesced.txt" + script.write_text( + """ +import json +import os +from pathlib import Path +import sys +import time + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + +time.sleep(0.05) +Path(os.environ["QUIESCED_MARKER"]).write_text("quiesced") +""".strip() + ) + + client = HarnessClient( + HarnessConfig( + _launch_args=(sys.executable, str(script)), + env={"QUIESCED_MARKER": str(marker)}, + shutdown_timeout_seconds=1, + ) + ) + client.start() + client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") + client.close() + + assert marker.read_text() == "quiesced" + + def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None: script = tmp_path / "rejecting_runtime.py" script.write_text( diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index deaa65b8a8..829c3b73f8 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -62,6 +62,14 @@ def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: assert build_python_release.PLATFORMS["macos-arm64"][1] == "deepseek-harness-sdk-runtime-macos-arm64" +def test_windows_wheel_tag_and_payload_are_x64_only() -> None: + assert build_python_release.PLATFORMS["win-x64"] == ( + "win_amd64", + "deepseek-harness-sdk-runtime-win-x64.exe", + ) + assert not any(name.startswith("win-") and name != "win-x64" for name in build_python_release.PLATFORMS) + + def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None: manifest = tmp_path / "platforms.json" manifest.write_text('{"macos-arm64":{"tag":"macosx_14_0_arm64"}}\n') @@ -85,7 +93,10 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() -@pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) +@pytest.mark.parametrize( + ("target", "with_helper"), + [("linux-x64", False), ("macos-arm64", True), ("win-x64.exe", False)], +) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool ) -> None: @@ -93,7 +104,11 @@ def test_stage_runtime_copies_platform_payload( executable.write_bytes(b"runtime") executable.chmod(0o755) expected = {executable.name: b"runtime"} - ripgrep = Path(f"{executable}-rg") + ripgrep = ( + executable.with_name(f"{executable.stem}-rg.exe") + if executable.suffix == ".exe" + else Path(f"{executable}-rg") + ) ripgrep.write_bytes(b"ripgrep") ripgrep.chmod(0o755) expected[ripgrep.name] = b"ripgrep" diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index fc54171b75..beaf5cfd6b 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -55,6 +55,30 @@ def test_runtime_requires_spawn_helper_only_on_macos( assert runtime.bundled_runtime_path() == linux +def test_windows_runtime_uses_exe_payload_and_exe_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + executable = runtime_dir / "deepseek-harness-sdk-runtime-win-x64.exe" + executable.touch() + (runtime_dir / "deepseek-harness-sdk-runtime-win-x64-rg.exe").touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "win-x64") + + assert runtime.bundled_runtime_path() == executable + + +def test_current_platform_supports_windows_x64_only(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(runtime.sys, "platform", "win32") + monkeypatch.setattr(runtime.platform, "machine", lambda: "AMD64") + assert runtime._current_platform_tag() == "win-x64" + + monkeypatch.setattr(runtime.platform, "machine", lambda: "ARM64") + with pytest.raises(FileNotFoundError, match="Windows x64"): + runtime._current_platform_tag() + + def test_runtime_requires_ripgrep_sidecar( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/scripts/build-exe-for-python-sdk-native-pty.spec.ts b/scripts/build-exe-for-python-sdk-native-pty.spec.ts index 5dd6588955..cc7d0ef7fa 100644 --- a/scripts/build-exe-for-python-sdk-native-pty.spec.ts +++ b/scripts/build-exe-for-python-sdk-native-pty.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' +import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' const roots: string[] = [] @@ -35,6 +35,24 @@ describe('resolveLinuxNodePtyAddon', () => { }) }) +describe('resolveWindowsNodePtyAddons', () => { + it('requires both ConPTY addons from the x64 prebuild', () => { + const root = temporaryPackage() + const conpty = createAddon(root, 'prebuilds', 'win32-x64', 'conpty.node') + const consoleList = createAddon(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node') + + expect(resolveWindowsNodePtyAddons(root, 'x64')).toEqual([conpty, consoleList]) + }) + + it('names every missing Windows addon', () => { + const root = temporaryPackage() + + expect(() => resolveWindowsNodePtyAddons(root, 'x64')).toThrow( + `Windows node-pty addons are missing: ${join(root, 'prebuilds', 'win32-x64', 'conpty.node')}, ${join(root, 'prebuilds', 'win32-x64', 'conpty_console_list.node')}`, + ) + }) +}) + function temporaryPackage(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-node-pty-addon-')) roots.push(root) diff --git a/scripts/build-exe-for-python-sdk-native-pty.ts b/scripts/build-exe-for-python-sdk-native-pty.ts index 02fa864d73..3ce5295d5c 100644 --- a/scripts/build-exe-for-python-sdk-native-pty.ts +++ b/scripts/build-exe-for-python-sdk-native-pty.ts @@ -21,3 +21,25 @@ export function resolveLinuxNodePtyAddon( `build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`, ) } + +/** + * Require both node-pty addons used by the Windows ConPTY backend. + * @param packageDirectory - staged node-pty package directory. + * @param arch - Windows target architecture. + * @returns the existing addon paths in load order. + */ +export function resolveWindowsNodePtyAddons( + packageDirectory: string, + arch: 'x64', +): string[] { + const directory = join(packageDirectory, 'prebuilds', `win32-${arch}`) + const addons = [ + join(directory, 'conpty.node'), + join(directory, 'conpty_console_list.node'), + ] + const missing = addons.filter(path => !existsSync(path)) + if (missing.length > 0) { + throw new Error(`build-exe-for-python-sdk: Windows node-pty addons are missing: ${missing.join(', ')}.`) + } + return addons +} diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts new file mode 100644 index 0000000000..c3fe4a15a6 --- /dev/null +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -0,0 +1,81 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '..') +const script = resolve(root, 'scripts/build-exe-for-python-sdk.ts') +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function run(env: NodeJS.ProcessEnv, ...args: string[]) { + return spawnSync(process.execPath, ['--import', 'tsx/esm', script, ...args], { + cwd: root, + encoding: 'utf8', + env: isolatedPnpmEnvironment(env), + }) +} + +describe('Python runtime executable builder CLI', () => { + it('runs pnpm through its JavaScript entrypoint without a command shell', () => { + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cjs' }, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`) + expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs dlx @yao-pkg/pkg@6.21.0`) + expect(result.stdout).not.toMatch(/pnpm\.cmd/i) + }) + + it('resolves the pnpm package behind a Windows command shim', () => { + const setup = mkdtempSync(join(tmpdir(), 'dsh-pnpm-home-')) + temporaryDirectories.push(setup) + const home = join(setup, 'node_modules', '.bin') + const entrypoint = join(setup, 'node_modules', 'pnpm', 'bin', 'pnpm.mjs') + mkdirSync(home, { recursive: true }) + mkdirSync(dirname(entrypoint), { recursive: true }) + writeFileSync(entrypoint, '') + + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cmd', PNPM_HOME: home }, + '--skip-build', + '--dry-run', + '--targets=node24-macos-arm64', + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain(`${process.execPath} ${entrypoint} run verify-runtime-closure`) + expect(result.stdout).not.toMatch(/pnpm\.cmd/i) + }) + + it('rejects a Windows arm64 product before any build step', () => { + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cjs' }, + '--skip-build', + '--dry-run', + '--targets=node24-win-arm64', + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Windows supports x64 only') + expect(result.stdout).toBe('') + }) +}) + +function isolatedPnpmEnvironment(overrides: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const environment = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !['npm_execpath', 'pnpm_home'].includes(key.toLowerCase())), + ) + return { ...environment, ...overrides } +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index c8c30b4301..c7c8cfed66 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -9,9 +9,9 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' -import { basename, dirname, join, resolve, sep } from 'node:path' +import { basename, dirname, extname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' -import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' +import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' const root = resolve(import.meta.dirname, '..') @@ -63,7 +63,7 @@ const ASSET_GLOBS = [ 'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*', ] -const PLATFORMS = ['linux', 'macos'] as const +const PLATFORMS = ['linux', 'macos', 'win'] as const const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] @@ -83,10 +83,7 @@ class Target { private constructor( /** pkg Node range (`node`). */ readonly nodeRange: string, - /** - * pkg platform tag. Windows is a documented non-goal - * (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). - */ + /** pkg platform tag. */ readonly platform: Platform, /** pkg CPU tag. */ readonly arch: Arch, @@ -117,6 +114,9 @@ class Target { if (!isArch(arch)) { throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`) } + if (platform === 'win' && arch !== 'x64') { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: Windows supports x64 only.`) + } return new Target(nodeRange, platform, arch) } @@ -125,7 +125,13 @@ class Target { * @returns the host target; throws on an unsupported host platform or arch. */ static host(): Target { - const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined + const platform = process.platform === 'darwin' + ? 'macos' + : process.platform === 'linux' + ? 'linux' + : process.platform === 'win32' + ? 'win' + : undefined if (platform === undefined) { throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`) } @@ -133,6 +139,9 @@ class Target { if (arch === undefined) { throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`) } + if (platform === 'win' && arch !== 'x64') { + throw new Error('build-exe-for-python-sdk: Windows supports x64 only; use an x64 Node process.') + } return new Target(DEFAULT_NODE_RANGE, platform, arch) } } @@ -200,7 +209,7 @@ class BuildCli { return [ 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', '', - ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.', + ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64.', ' Default: the host platform only (on node24).', ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).', ' --dry-run print every command and config patch without executing.', @@ -212,8 +221,27 @@ class BuildCli { } } -function pnpmBin(): string { - return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +function pnpmInvocation(args: string[]): [command: string, args: string[]] { + const entrypoint = process.env.npm_execpath?.trim() + if (entrypoint !== undefined && entrypoint !== '') { + const extension = extname(entrypoint).toLowerCase() + if (extension === '.js' || extension === '.cjs' || extension === '.mjs') { + return [process.execPath, [entrypoint, ...args]] + } + if (extension !== '.cmd') return [entrypoint, args] + } + const home = process.env.PNPM_HOME?.trim() + if (home !== undefined && home !== '') { + const packageBin = resolve(home, '..', 'pnpm', 'bin') + for (const filename of ['pnpm.mjs', 'pnpm.cjs']) { + const candidate = resolve(packageBin, filename) + if (existsSync(candidate)) return [process.execPath, [candidate, ...args]] + } + } + if (process.platform === 'win32') { + throw new Error('build-exe-for-python-sdk: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.') + } + return ['pnpm', args] } /** @@ -241,7 +269,7 @@ class SingleExeBuild { /** Verify the closure before compiling or packaging. */ async verifyClosure(): Promise { - await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure']) + await this.runPnpm('runtime dependency closure', ['run', 'verify-runtime-closure']) } /** Build all package artifacts unless `--skip-build` was passed. */ @@ -250,7 +278,7 @@ class SingleExeBuild { console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)') return } - await this.run('build', pnpmBin(), ['run', 'build']) + await this.runPnpm('build', ['run', 'build']) } /** Clear and deploy the runtime closure into the node carrier. */ @@ -260,7 +288,7 @@ class SingleExeBuild { } if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`) else await rm(this.staging, { recursive: true, force: true }) - await this.run('deploy', pnpmBin(), [ + await this.runPnpm('deploy', [ '--filter', DEPLOY_ROOT_PACKAGE, 'deploy', @@ -393,10 +421,11 @@ class SingleExeBuild { * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required. */ async pack(target: Target): Promise { - const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const product = target.platform === 'win' ? `${productBase}.exe` : productBase await this.prepareNativePty(target) if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true }) - await this.run(`pkg ${target.spec}`, pnpmBin(), [ + await this.runPnpm(`pkg ${target.spec}`, [ 'dlx', PKG_SPEC, this.staging, @@ -424,16 +453,19 @@ class SingleExeBuild { /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ private async copyRipgrepSidecar(target: Target, product: string): Promise { - const platform = target.platform === 'macos' ? 'darwin' : target.platform + const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform + const executable = target.platform === 'win' ? 'rg.exe' : 'rg' const source = join( this.staging, 'node_modules', '@vscode', `ripgrep-${platform}-${target.arch}`, 'bin', - 'rg', + executable, ) - const destination = `${product}-rg` + const destination = target.platform === 'win' + ? `${product.slice(0, -'.exe'.length)}-rg.exe` + : `${product}-rg` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return destination @@ -455,7 +487,6 @@ class SingleExeBuild { const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build') if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - if (target.platform !== 'linux') return const packageDirectory = join( root, 'packages', @@ -464,6 +495,21 @@ class SingleExeBuild { 'node_modules', 'node-pty', ) + if (target.platform === 'win') { + if (target.arch !== 'x64') { + throw new Error('build-exe-for-python-sdk: Windows supports x64 only.') + } + const host = Target.host() + if (target.platform !== host.platform || target.arch !== host.arch) { + throw new Error( + 'build-exe-for-python-sdk: build the Windows runtime under x64 Node on its target host; ' + + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`, + ) + } + resolveWindowsNodePtyAddons(join(this.staging, 'node_modules', 'node-pty'), target.arch) + return + } + if (target.platform !== 'linux') return const destination = join(stagedBuild, 'Release', 'pty.node') const source = resolveLinuxNodePtyAddon(packageDirectory, target.arch) if (this.cli.dryRun) { @@ -553,6 +599,12 @@ class SingleExeBuild { }) }) } + + /** Run pnpm through its JavaScript entrypoint when the caller supplies one. */ + private async runPnpm(label: string, args: string[]): Promise { + const [command, invocationArgs] = pnpmInvocation(args) + await this.run(label, command, invocationArgs) + } } async function main(): Promise { diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index c546c6bb42..307fe759dd 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -47,9 +47,12 @@ def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]] PLATFORMS = load_platforms() -def runtime_suffixes(executable_name: str) -> tuple[str, ...]: - suffixes = ("", "-rg") - return (*suffixes, "-spawn-helper") if "-macos-" in executable_name else suffixes +def runtime_filenames(executable_name: str) -> tuple[str, ...]: + """Return the exact platform payload names for one runtime executable.""" + if executable_name.endswith(".exe"): + return (executable_name, f"{executable_name.removesuffix('.exe')}-rg.exe") + names = (executable_name, f"{executable_name}-rg") + return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names def main() -> None: @@ -210,8 +213,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) - for suffix in runtime_suffixes(executable_name): - shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}") + source_directory = executable.parent + for filename in runtime_filenames(executable_name): + shutil.copy2(source_directory / filename, runtime_dir / filename) def verify_wheel( @@ -250,13 +254,13 @@ def verify_wheel( ] if package == "runtime": assert platform is not None - expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])] + expected_files = sorted(runtime_filenames(platform[1])) found_files = sorted(Path(name).name for name in runtime_files) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") for runtime_file in runtime_files: mode = archive.getinfo(runtime_file).external_attr >> 16 - if mode & stat.S_IXUSR == 0: + if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 6a395afe30..90dbf20799 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -21,6 +21,7 @@ const platforms = { 'linux-x64': { tag: 'manylinux_2_28_x86_64', executable: 'runtime-linux-x64' }, 'linux-arm64': { tag: 'manylinux_2_28_aarch64', executable: 'runtime-linux-arm64' }, 'macos-arm64': { tag: 'macosx_14_0_arm64', executable: 'runtime-macos-arm64' }, + 'win-x64': { tag: 'win_amd64', executable: 'runtime-win-x64.exe' }, } function workspace(root: string, name: string, manifest: Record): void { @@ -35,7 +36,7 @@ afterEach(() => { }) describe('verifyRuntimeClosure', () => { - it('requires only plugins active for a Linux or macOS target', async () => { + it('requires only plugins active for each published target', async () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, 'python/sdk-runtime/platforms.json': platforms, @@ -52,6 +53,9 @@ describe('verifyRuntimeClosure', () => { - id: macos name: '@scope/macos' disabled: !!js process.platform !== 'darwin' + - id: windows + name: '@scope/windows' + disabled: !!js process.platform !== 'win32' `, }) @@ -61,6 +65,7 @@ describe('verifyRuntimeClosure', () => { expect(result.failures).toEqual([ 'standard preset -> @scope/linux (linux-arm64, linux-x64)', 'standard preset -> @scope/macos (macos-arm64)', + 'standard preset -> @scope/windows (win-x64)', ]) }) @@ -78,7 +83,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64)', + 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64, win-x64)', ]) }) @@ -112,7 +117,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64)', + 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64, win-x64)', ]) }) diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index d0127fc68d..b0dac1454b 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -181,6 +181,7 @@ function disabledOnPlatform(value: unknown, processPlatform: string): boolean { function processPlatformForTarget(target: string): string { if (target.startsWith('linux-')) return 'linux' if (target.startsWith('macos-')) return 'darwin' + if (target.startsWith('win-')) return 'win32' throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`) } From 026a37fc070d5a7e4416d76bfd6eb9b2e184f6e3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:39:43 +0800 Subject: [PATCH 38/42] ci(python): gate the Windows x64 installed wheel Add node24-win-x64 to the required pull-request and public-release matrices on a native windows-2025 runner, and publish the same win_amd64 artifact from the GitLab tag pipeline. GitHub uses Git Bash for the shared release script while selecting the Windows venv's Scripts/python.exe explicitly; the Linux and macOS legs retain their existing commands and native checks. Run the complete installed-wheel keyless suite and the trusted two-turn DeepSeek smoke on Windows exactly as on the existing targets. Make the minimal blackbox choose persistent PowerShell on Windows, keep advanced and restart snapshots platform-stable by disabling both one-shot shell variants, locate the generated dsh.exe console command, and validate text lines without assuming POSIX newlines. Workflow tests pin the four-target matrix, Windows runner and wheel tag, cross-platform venv selection, GitLab publication dependency, and full blackbox invocation. The existing POSIX minimal snapshot changes only its platform-neutral prompt wording; Windows owns a separate model-visible snapshot. --- .../workflows/build-exe-for-python-sdk.yml | 62 ++- .github/workflows/ci.yml | 2 +- .github/workflows/python-release.yml | 5 +- .gitlab-ci.yml | 41 +- scripts/ci-workflow.spec.ts | 30 +- scripts/smoke-python-runtime.py | 56 ++- .../minimal/model-visible.json | 8 +- .../minimal/win-x64/model-visible.json | 430 ++++++++++++++++++ 8 files changed, 586 insertions(+), 48 deletions(-) create mode 100644 scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 04501d5deb..056b7a2a2c 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -2,7 +2,7 @@ name: Build single-exe # Native builds for the release targets; see # .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. -# A full target run retains one SDK wheel and three runtime wheels; subset +# A full target run retains one SDK wheel and four runtime wheels; subset # dispatch retains the SDK wheel and selected runtime wheels. Bare executables # and source closures are test inputs. Run manually, label a PR `build-exe` # (remove and reapply to rerun), or call it from the Python release workflow. @@ -11,7 +11,7 @@ on: workflow_call: inputs: targets: - description: Comma-separated pkg targets to build; empty builds all three. + description: Comma-separated pkg targets to build; empty builds all four. type: string required: false default: '' @@ -34,8 +34,8 @@ on: targets: description: >- Comma-separated pkg targets to build. Any subset of: - node24-linux-x64, node24-linux-arm64, node24-macos-arm64. - Empty builds all three. + node24-linux-x64, node24-linux-arm64, node24-macos-arm64, + node24-win-x64. Empty builds all four. type: string required: false default: '' @@ -90,7 +90,7 @@ jobs: id: plan env: # Label runs and blank dispatch inputs build all targets. - TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64' }} + TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64' }} run: | set -euo pipefail matrix='[]' @@ -104,8 +104,9 @@ jobs: node24-linux-x64) runner=ubuntu-latest ;; node24-linux-arm64) runner=ubuntu-24.04-arm ;; node24-macos-arm64) runner=macos-latest ;; + node24-win-x64) runner=windows-2025 ;; *) - echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64." + echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64, node24-win-x64." exit 1 ;; esac @@ -155,10 +156,22 @@ jobs: fail-fast: false matrix: include: ${{ fromJSON(needs.plan.outputs.matrix) }} + defaults: + run: + shell: bash steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm-js + + - name: Enable Windows Developer Mode (symlink support) + if: runner.os == 'Windows' + shell: pwsh + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" # setup-node's built-in pnpm store cache keys on platform AND arch, so # the Linux architectures sharing runner.os stay on separate caches. @@ -198,6 +211,7 @@ jobs: *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)" + pnpm_setup_root="$(realpath "$(dirname "$(dirname "$PNPM_HOME")")")" (cd "$addon_dir" && npm_config_build_from_source=true pnpm run install) addon="$addon_dir/build/Release/pty.node" [ -f "$addon_dir/build/Makefile" ] || { @@ -208,7 +222,7 @@ jobs: --user "$(id -u):$(id -g)" \ -v "$PWD:$PWD" \ -v "$HOME/.cache/node-gyp:$HOME/.cache/node-gyp:ro" \ - -v "$HOME/setup-pnpm:$HOME/setup-pnpm:ro" \ + -v "$pnpm_setup_root:$pnpm_setup_root:ro" \ -w "$addon_dir" \ "$image" \ bash -euxo pipefail -c \ @@ -236,13 +250,21 @@ jobs: set -euo pipefail platform="${TARGET#node24-}" exe="$PWD/dist-exe/deepseek-harness-sdk-runtime-$platform" - [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } case "$platform" in linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl ;; + win-x64) + exe="$exe.exe" + wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-win_amd64.whl + ;; *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; esac + if [ "$RUNNER_OS" = Windows ]; then + [ -f "$exe" ] || { echo "::error::$exe missing"; exit 1; } + else + [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } + fi echo "platform=$platform" >> "$GITHUB_OUTPUT" echo "exe=$exe" >> "$GITHUB_OUTPUT" echo "wheel=$wheel" >> "$GITHUB_OUTPUT" @@ -261,24 +283,32 @@ jobs: path: dist-python - name: Install local SDK and runtime wheels into a clean venv + id: smoke-venv env: RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail - python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ + venv="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))')" + python -m venv "$venv" + if [ "$RUNNER_OS" = Windows ]; then + smoke_python="$(cygpath -u "$venv")/Scripts/python.exe" + else + smoke_python="$venv/bin/python" + fi + "$smoke_python" -m pip install \ "dist-python/$SDK_WHEEL" \ "dist-python/$RUNTIME_WHEEL" + echo "python=$smoke_python" >> "$GITHUB_OUTPUT" - name: Run installed-wheel keyless black-box tests run: | set -euo pipefail - blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox" - mkdir -p "$blackbox_root" + blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-"))')" + if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "${{ steps.smoke-venv.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario all \ --installed-wheel @@ -309,11 +339,11 @@ jobs: DEEPSEEK_BASE_URL: https://api.deepseek.com run: | set -euo pipefail - blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox-live" - mkdir -p "$blackbox_root" + blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-live-"))')" + if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "${{ steps.smoke-venv.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario sdk-live \ --installed-wheel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9d4cac33a..429796c3b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,7 +302,7 @@ jobs: name: python runtime / release-shaped matrix uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64 ci: true secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index d888d17a8a..18524523b9 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -24,10 +24,10 @@ concurrency: jobs: build: - name: Build four wheels + name: Build five wheels uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64 release: true python-compat: @@ -151,6 +151,7 @@ jobs: "deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl" \ "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl" \ "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl" \ + "deepseek_harness_runtime_bin-$VERSION-py3-none-win_amd64.whl" \ "deepseek_harness_sdk-$VERSION-py3-none-any.whl" > "$expected" find dist -maxdepth 1 -type f -name '*.whl' -exec basename {} \; | sort > "$actual" diff -u "$expected" "$actual" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index faa9402c42..0a663cadf7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,6 +97,42 @@ runtime-macos-arm64: - job: sdk-wheel artifacts: true +runtime-windows-x64: + stage: build + tags: [windows-x64] + variables: + PKG_TARGET: node24-win-x64 + PLATFORM: win-x64 + needs: + - job: sdk-wheel + artifacts: true + before_script: + - python -m venv .ci-python + - $env:DSH_VERSION = (& .ci-python\Scripts\python.exe -c 'import json; print(json.load(open("package.json"))["version"])') + - $env:DSH_WHEEL_VERSION = (& .ci-python\Scripts\python.exe -c 'import runpy; release = runpy.run_path("scripts/build-python-release.py"); print(release["pep440_version"](release["repository_version"]()))') + - if ($env:CI_COMMIT_TAG -ne "python-v$env:DSH_VERSION") { throw "Tag $env:CI_COMMIT_TAG does not match package.json version $env:DSH_VERSION" } + - .ci-python\Scripts\python.exe -m pip install uv==0.11.23 + script: + - corepack enable + - pnpm install --frozen-lockfile + - pnpm run verify-runtime-closure + - pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=$env:PKG_TARGET + - $exe = Join-Path $PWD "dist-exe\deepseek-harness-sdk-runtime-win-x64.exe" + - if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw "Runtime executable is missing at $exe" } + - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe $exe + - .ci-python\Scripts\python.exe scripts/build-python-release.py --package runtime --tag $env:CI_COMMIT_TAG --platform $env:PLATFORM --runtime-exe $exe --output-dir "release/$env:PLATFORM" + - python -m venv .wheel-smoke + - .wheel-smoke\Scripts\python.exe -m pip install "release/sdk/deepseek_harness_sdk-$env:DSH_WHEEL_VERSION-py3-none-any.whl" "release/win-x64/deepseek_harness_runtime_bin-$env:DSH_WHEEL_VERSION-py3-none-win_amd64.whl" + - Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + - Remove-Item Env:DSH_RUNTIME_MODE -ErrorAction SilentlyContinue + - $blackbox = Join-Path $env:TEMP "dsh-sdk-blackbox-$([guid]::NewGuid())" + - New-Item -ItemType Directory -Path $blackbox | Out-Null + - Push-Location $blackbox + - try { & "$env:CI_PROJECT_DIR\.wheel-smoke\Scripts\python.exe" "$env:CI_PROJECT_DIR\scripts\smoke-python-runtime.py" --scenario all --installed-wheel } finally { Pop-Location } + artifacts: + paths: [release/win-x64/*.whl] + expire_in: 1 week + publish-python: stage: publish tags: [linux-x64] @@ -110,6 +146,8 @@ publish-python: artifacts: true - job: runtime-macos-arm64 artifacts: true + - job: runtime-windows-x64 + artifacts: true before_script: - python3 -m venv .ci-python - . .ci-python/bin/activate @@ -118,11 +156,12 @@ publish-python: - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } - python -m pip install twine==6.2.0 script: - - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 + - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 5 - test -f "release/sdk/deepseek_harness_sdk-${DSH_WHEEL_VERSION}-py3-none-any.whl" - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-macosx_14_0_arm64.whl" + - test -f "release/win-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-win_amd64.whl" - python -m twine check release/*/*.whl - export TWINE_USERNAME=gitlab-ci-token - export TWINE_PASSWORD="$CI_JOB_TOKEN" diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 56334523aa..dcaa5e19a2 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -228,7 +228,7 @@ describe('CI workflow', () => { name: 'python runtime / release-shaped matrix', uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { - targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64', ci: true, }, secrets: { @@ -320,7 +320,7 @@ describe('Python release workflows', () => { expect(build).toMatchObject({ uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { - targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64', release: true, }, }) @@ -390,10 +390,11 @@ describe('Python release workflows', () => { const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') + const cleanVenv = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv') const installedKeyless = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests') const realApiPreflight = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test') const installedRealApi = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test') - if (!isRecord(installedKeyless) || !isRecord(realApiPreflight) || !isRecord(installedRealApi)) { + if (!isRecord(cleanVenv) || !isRecord(installedKeyless) || !isRecord(realApiPreflight) || !isRecord(installedRealApi)) { throw new TypeError('Python wheel builder must define installed-wheel keyless and real API steps') } expect(call.inputs).toHaveProperty('targets') @@ -407,11 +408,15 @@ describe('Python release workflows', () => { expect(workflow.concurrency).toMatchObject({ group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}', }) + expect(build.defaults).toMatchObject({ run: { shell: 'bash' } }) expect(plan.if).toContain('inputs.ci') expect(plan.if).toContain('inputs.release') expect(JSON.stringify(plan.steps)).toContain('pep440_version') const workflowJson = JSON.stringify(workflow) expect(workflowJson).toContain('macosx_14_0_arm64') + expect(workflowJson).toContain('win_amd64') + expect(workflowJson).toContain('node24-win-x64') + expect(workflowJson).toContain('windows-2025') expect(workflowJson).toContain('dist-python/$SDK_WHEEL') expect(workflowJson).toContain('dist-python/$RUNTIME_WHEEL') expect(workflowJson).toContain('/work/dist-python/$SDK_WHEEL') @@ -422,7 +427,8 @@ describe('Python release workflows', () => { expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') expect(JSON.stringify(manylinuxAddon)).toContain('npm_config_build_from_source=true pnpm run install') - expect(JSON.stringify(manylinuxAddon)).toContain('$HOME/setup-pnpm:$HOME/setup-pnpm:ro') + expect(JSON.stringify(manylinuxAddon)).toContain('pnpm_setup_root') + expect(JSON.stringify(manylinuxAddon)).toContain('$pnpm_setup_root:$pnpm_setup_root:ro') expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt') expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28') expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) @@ -432,6 +438,7 @@ describe('Python release workflows', () => { expect(JSON.stringify(installedKeyless)).toContain('--installed-wheel') expect(JSON.stringify(installedKeyless)).toContain('env -u PYTHONPATH') expect(JSON.stringify(installedKeyless)).toContain('-u DSH_RUNTIME_MODE') + expect(JSON.stringify(cleanVenv)).toContain('Scripts/python.exe') expect(realApiPreflight).toMatchObject({ env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' }, }) @@ -469,6 +476,21 @@ describe('Python release workflows', () => { expect(macosCheck).toContain('scripts/check-macos-deployment-target.py') expect(macosCheck).toContain('"$EXE" "$EXE-spawn-helper"') }) + + it('builds and black-box tests the Windows x64 wheel in GitLab', () => { + const workflow = loadWorkflow('.gitlab-ci.yml') + const windows = workflow['runtime-windows-x64'] + const publish = workflow['publish-python'] + if (!isRecord(windows) || !Array.isArray(windows.script) || !isRecord(publish) || !Array.isArray(publish.needs)) { + throw new TypeError('GitLab CI must define the Windows runtime and aggregate publication jobs') + } + + expect(windows.tags).toEqual(['windows-x64']) + expect(windows.variables).toMatchObject({ PKG_TARGET: 'node24-win-x64', PLATFORM: 'win-x64' }) + expect(JSON.stringify(windows.script)).toContain('win_amd64.whl') + expect(JSON.stringify(windows.script)).toContain('--scenario all --installed-wheel') + expect(publish.needs).toContainEqual({ job: 'runtime-windows-x64', artifacts: true }) + }) }) describe('Issue lifecycle workflow', () => { diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 4a5a3eb0be..b28e740495 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -30,7 +30,7 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" -MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." +MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent shell and string-replacement editor." MINIMAL_TEXT = "minimal agent smoke ok" MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." @@ -41,11 +41,20 @@ MCP_TEXT = "MCP client smoke ok" PROFILE_PLUGIN_PROMPT = "Verify the Python-installed dsh profile plugin." PROFILE_PLUGIN_TEXT = "profile plugin smoke ok" PROFILE_PLUGIN_MARKER = "PYTHON_INSTALLED_DSH_PROFILE_PLUGIN" -MINIMAL_BASH_COMMAND = ( - "counter=$(( ${counter:-0} + 1 )); export counter; " - "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " - "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" +IS_WINDOWS = sys.platform == "win32" +MINIMAL_SHELL_TOOL = "pwsh" if IS_WINDOWS else "bash" +MINIMAL_SHELL_COMMAND = ( + "$global:dshSdkCounter = [int]$global:dshSdkCounter + 1; " + 'Write-Output "COUNT=$global:dshSdkCounter CWD=$((Get-Location).Path)"; ' + "if ($global:dshSdkCounter -eq 1) { Set-Location $env:TEMP }" + if IS_WINDOWS + else ( + "counter=$(( ${counter:-0} + 1 )); export counter; " + "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " + "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" + ) ) +MINIMAL_SHELL_SECOND_CWD = str(Path(tempfile.gettempdir()).resolve()) if IS_WINDOWS else "/tmp" LEGACY_CUSTOM_DISABLED_ROWS = ( "agent-instructions", "goal", @@ -108,6 +117,8 @@ ADVANCED_SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl" MINIMAL_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal" ) +if IS_WINDOWS: + MINIMAL_SNAPSHOT_DIRECTORY /= "win-x64" MINIMAL_SNAPSHOT_FILENAMES = ("model-visible.json",) RESTART_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart" @@ -301,8 +312,8 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if minimal_prompt is not None: return tool_call_chunks( "minimal-bash-1", - "bash", - {"command": MINIMAL_BASH_COMMAND}, + MINIMAL_SHELL_TOOL, + {"command": MINIMAL_SHELL_COMMAND}, ) scenario_prompts = { SNAPSHOT_DIRECT_CHILD_PROMPT, @@ -438,17 +449,18 @@ def minimal_tool_followup( """Verify the checked-in minimal composition's PTY and editor.""" if not call_id.startswith("minimal-"): return None - if call_id == "minimal-bash-1" and tool_name == "bash": + if call_id == "minimal-bash-1" and tool_name == MINIMAL_SHELL_TOOL: if "COUNT=1" not in tool_text: - raise AssertionError(f"first persistent bash call lost its output: {tool_text}") + raise AssertionError(f"first persistent shell call lost its output: {tool_text}") return tool_call_chunks( "minimal-bash-2", - "bash", - {"command": MINIMAL_BASH_COMMAND}, + MINIMAL_SHELL_TOOL, + {"command": MINIMAL_SHELL_COMMAND}, ) - if call_id == "minimal-bash-2" and tool_name == "bash": - if "COUNT=2 CWD=/tmp" not in tool_text: - raise AssertionError(f"persistent bash did not retain state: {tool_text}") + if call_id == "minimal-bash-2" and tool_name == MINIMAL_SHELL_TOOL: + expected = f"COUNT=2 CWD={MINIMAL_SHELL_SECOND_CWD}" + if expected.lower() not in tool_text.lower(): + raise AssertionError(f"persistent shell did not retain state: {tool_text}") messages = body.get("messages") if not isinstance(messages, list): raise AssertionError("persistent editor smoke request has no messages") @@ -800,8 +812,9 @@ def smoke_sdk_live() -> None: sessions = dsh_home / "sessions" marker = root / "live-api-marker.txt" session_id = "installed-wheel-live-api" + shell_tool = "pwsh" if IS_WINDOWS else "bash" create_prompt = ( - "Use the bash tool to create the file at the absolute path below with exactly one line " + f"Use the {shell_tool} tool to create the file at the absolute path below with exactly one line " f"containing {LIVE_API_SENTINEL}. Then reply with exactly {LIVE_API_SENTINEL}.\n{marker}" ) verify_prompt = ( @@ -845,8 +858,8 @@ def smoke_sdk_live() -> None: raise AssertionError(f"{label} turn returned {result.final_response!r}") if not marker.is_file(): raise AssertionError(f"real-model tool turn did not create {marker}") - if marker.read_bytes() != f"{LIVE_API_SENTINEL}\n".encode(): - raise AssertionError(f"real-model tool turn wrote unexpected bytes to {marker}") + if marker.read_text(encoding="utf-8").splitlines() != [LIVE_API_SENTINEL]: + raise AssertionError(f"real-model tool turn wrote unexpected text to {marker}") assert_zstd_session_log(sessions) @@ -921,6 +934,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: {"id": "session-log-deepseek", "config": {"enabled": True}}, *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, { "id": "tool-subagent", "config": { @@ -989,7 +1003,7 @@ def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) - raise AssertionError(f"minimal agent run emitted no final response: {result.events}") if editor_path.read_text() != "created by packaged editor\n": raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") - assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2") files = build_minimal_snapshot_files(MockModelHandler.requests[first_request:], root) compare_snapshot_files( @@ -1105,7 +1119,7 @@ def smoke_sdk_profile_plugin(base_url: str) -> None: "insert": [{"id": "python-sdk-blackbox-plugin", "name": "dsh-python-blackbox-plugin"}], }], indent=2)) - dsh = Path(sysconfig.get_path("scripts")) / "dsh" + dsh = Path(sysconfig.get_path("scripts")) / ("dsh.exe" if IS_WINDOWS else "dsh") environment = {**os.environ, "DSH_HOME": str(dsh_home)} installed = subprocess.run( [str(dsh), "plugin", "--profile", "sdk", "add", f"file:{plugin}"], @@ -1170,6 +1184,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) {"id": "session-log-deepseek", "config": {"enabled": True}}, *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, { "id": "tool-subagent", "config": { @@ -1243,6 +1258,7 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots {"id": "session-log-deepseek", "config": {"enabled": True}}, *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, { "id": "tool-subagent", "config": { @@ -1758,7 +1774,7 @@ def compare_snapshot_files( if update: directory.mkdir(parents=True, exist_ok=True) for name, content in files.items(): - (directory / name).write_text(content, encoding="utf-8") + (directory / name).write_text(content, encoding="utf-8", newline="\n") print(f"smoke-python-runtime: updated snapshots in {directory}") existing = { diff --git a/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json b/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json index a3223c8d76..86fcecb5b1 100644 --- a/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json +++ b/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json @@ -81,7 +81,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" } ] }, @@ -167,7 +167,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" }, { "role": "assistant", @@ -267,7 +267,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" }, { "role": "assistant", @@ -381,7 +381,7 @@ }, { "role": "user", - "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}/created.txt" }, { "role": "assistant", diff --git a/scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json b/scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json new file mode 100644 index 0000000000..d630a7bf10 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/minimal/win-x64/model-visible.json @@ -0,0 +1,430 @@ +[ + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-2", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-2", + "text": "{{tool-result}}" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "pwsh", + "description": "Run commands in a PowerShell shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* State is persistent across command calls and discussions with the user.\n* Use native Windows paths (C:\\...) and $env:NAME variables; this is PowerShell, not bash.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent shell and string-replacement editor.\nEditor path: {{cwd}}\\created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-2", + "name": "pwsh" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-2", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-editor", + "name": "str_replace_editor" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-editor", + "text": "{{tool-result}}" + } + ] + } +] From 28442337cfa387ed2a2f92a8b7551026c49db23e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:10:58 +0800 Subject: [PATCH 39/42] feat(python-example): select the persistent shell by platform Make the checked-in minimal SDK overlay disable both one-shot shell rows and mount exactly one persistent PTY stack: Bash on Linux/macOS and PowerShell on Windows. The SDK server, explicit dsh home, persistence, editor, timeout, and reduced tool catalog remain unchanged. Update the runnable example and tutorial to list Windows x64 as supported, describe the platform-selected shell, and remove the obsolete POSIX-only restriction. This keeps the documented first Python task executable through the packaged Windows dsh profile instead of advertising a Linux-only overlay on a Windows-capable SDK. --- ...4-standalone-sdk-minimal-profile.i18n.yaml | 4 ++-- ...26-08-24-standalone-sdk-minimal-profile.md | 4 ++-- ...08-24-standalone-sdk-minimal-profile.zh.md | 4 ++-- ...nimal-preset-owns-rl-composition.i18n.yaml | 4 ++-- ...8-10-minimal-preset-owns-rl-composition.md | 2 +- ...0-minimal-preset-owns-rl-composition.zh.md | 2 +- ...l-profiles-bare-two-tool-runtime.i18n.yaml | 4 ++-- ...-minimal-profiles-bare-two-tool-runtime.md | 6 ++--- ...nimal-profiles-bare-two-tool-runtime.zh.md | 6 ++--- apps/cli/tests/built-bin.e2e.ts | 2 ++ docs/user/guide/python-sdk.i18n.yaml | 4 ++-- docs/user/guide/python-sdk.md | 8 +++---- docs/user/guide/python-sdk.zh.md | 8 +++---- examples/python-sdk-agent/README.i18n.yaml | 4 ++-- examples/python-sdk-agent/README.md | 4 ++-- examples/python-sdk-agent/README.zh.md | 4 ++-- .../tests/keyless-smoke.e2e.ts | 3 ++- packages/bundle/sdk-minimal/README.i18n.yaml | 4 ++-- packages/bundle/sdk-minimal/README.md | 7 +++--- packages/bundle/sdk-minimal/README.zh.md | 7 +++--- packages/bundle/sdk-minimal/cordis.patch.yml | 23 +++++++++++++++++++ packages/bundle/sdk-minimal/package.json | 1 + .../sdk-minimal/tests/sdk-minimal.spec.ts | 11 ++++++++- pnpm-lock.yaml | 3 +++ 24 files changed, 85 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml index 4a06faa161..0d7ca581c3 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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/architecture/2026-08-24-standalone-sdk-minimal-profile.md -2026-08-24-standalone-sdk-minimal-profile.md: bc1a177dc4a232201004e6869caa452a7d55deb9 -2026-08-24-standalone-sdk-minimal-profile.zh.md: ae6fdb95079f748f26758a30c968c27548e9a87f +2026-08-24-standalone-sdk-minimal-profile.md: 692bf9763f4ad710ff5cc819a7480b2f3e8d9b5f +2026-08-24-standalone-sdk-minimal-profile.zh.md: f39baad1b4429b73f13d601716dadd8376c71bfb diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md index bc1a177dc4..692bf9763f 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -22,9 +22,9 @@ The bundle reuses `@deepseek-ai/dsh-sdk-app` for command help, stdin EOF, and bo ### Explicit composition -The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, persistent Bash, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. The SDK initialization request owns the model id; `DSH_CONTEXT_WINDOW` supplies fallback capacity for models outside the adapter's advisory catalog. The persona comes from `DSH_SYSTEM_PROMPT`, and the credential from `DEEPSEEK_API_KEY`. +The bundle owns one DeepSeek adapter, SDK JSON-RPC serving, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell, the string-replace editor, and uncompressed JSONL sessions under `$DSH_HOME/sessions`. Linux and macOS mount Bash; Windows mounts PowerShell. The SDK initialization request owns the model id; `DSH_CONTEXT_WINDOW` supplies fallback capacity for models outside the adapter's advisory catalog. The persona comes from `DSH_SYSTEM_PROMPT`, and the credential from `DEEPSEEK_API_KEY`. -Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. This layer is POSIX-only because its persistent terminal uses Bash. +Harness identity, runtime context, workspace instructions, skills, model-facing job controls, compaction, settings, managed credentials, telemetry, Web tools, subagents, and every other base row are absent rather than hidden. The profile pins `danger-full-access`, `maxTokensAsSuccess: false`, and startup-only patch loading. ### Customization and Web diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md index ae6fdb9507..f39baad1b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -22,9 +22,9 @@ Status: implemented ### 显式组合 -该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。SDK 初始化请求拥有模型 id;`DSH_CONTEXT_WINDOW` 为不在适配器建议目录中的模型提供后备容量。Persona 来自 `DSH_SYSTEM_PROMPT`,凭据来自 `DEEPSEEK_API_KEY`。 +该组合包拥有一个 DeepSeek 适配器、SDK JSON-RPC 服务、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、按平台选择的持久 shell、字符串替换 editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话。Linux 与 macOS 挂载 Bash,Windows 挂载 PowerShell。SDK 初始化请求拥有模型 id;`DSH_CONTEXT_WINDOW` 为不在适配器建议目录中的模型提供后备容量。Persona 来自 `DSH_SYSTEM_PROMPT`,凭据来自 `DEEPSEEK_API_KEY`。 -Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。由于持久终端使用 Bash,此层只支持 POSIX。 +Harness 身份、运行时上下文、workspace 指令、skills、面向模型的 job 控制、compaction、settings、托管凭据、遥测、Web 工具、subagent 与其他所有 base 配置项均不存在,而不是被隐藏。该 profile 固定使用 `danger-full-access`、`maxTokensAsSuccess: false` 与仅启动时 patch 加载。 ### 自定义与 Web diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index 49ebbb6567..38476183a5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md -2026-08-10-minimal-preset-owns-rl-composition.md: 2e9a3e56252f8e91008a5559ad738a7ca678446b -2026-08-10-minimal-preset-owns-rl-composition.zh.md: 31df6ebfbc15f35208bc73b391725b2039fe7819 +2026-08-10-minimal-preset-owns-rl-composition.md: 4c296ed4af5df7a48bdfba6ff1972321cc2e54cf +2026-08-10-minimal-preset-owns-rl-composition.zh.md: 545f0a32fe9c7726d6fc910d0598174e7d7a3ec1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md index 2e9a3e5625..4c296ed4af 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -36,4 +36,4 @@ The standalone [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/RE ## Consequences -The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and `sdk-minimal` profile state the same two-tool behavior for their respective launch paths. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset does not support Windows agents. +The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and `sdk-minimal` profile share persistent-shell-plus-editor behavior for their respective launch paths; `sdk-minimal` selects PowerShell on Windows. Shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The Web preset's Bash backend requires the supported POSIX terminal substrate, so that preset does not support Windows agents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md index 31df6ebfbc..545f0a32fe 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -36,4 +36,4 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,它 ## 后果 -Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与 `sdk-minimal` profile 分别为各自启动路径声明相同的双工具行为。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 +Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与 `sdk-minimal` profile 在各自启动路径共享持久 shell 加 editor 的行为;`sdk-minimal` 在 Windows 上选择 PowerShell。Shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。Web preset 的 Bash 后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index 4a576e9339..64027860ca 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 6ea86832b632c631b7e02d6c486f3858fd2632a4 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 73f6f16a51c14a7c98915a84878b39596d12f245 +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 6ff0fc360e7187db0e5a93e7edc8b1e48eecdbfe +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 696538381bed54d46c35d6ebe9ba2142adce9d79 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index 6ea86832b6..6ff0fc360e 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -12,9 +12,9 @@ The two launch paths also have different configuration owners. Web mounts a per- ## Decision -Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. +The shipped Web minimal preset exposes persistent `bash` and `str_replace_editor`; the standalone profile exposes persistent `bash` on Linux/macOS or `pwsh` on Windows, plus the same editor. Both mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. The Web host retains its sandbox and approval services; the standalone profile mounts a danger-full-access sandbox policy and no approval service. Neither contributes model-facing policy context. -The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. +The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) remains a complete JSON-RPC process composition behind `dsh --profile sdk-minimal`. It mounts SDK startup and JSON-RPC serving, the local PTY and subprocess services required by the platform-selected persistent shell, `fs-local`, that shell's tool consumer, the editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. The persistent shell consumes the profile's danger-full-access sandbox policy; the editor is not confined by that policy. The [standalone-profile decision](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) owns this bundle placement and its separation from `dsh-base`. `DSH_SYSTEM_PROMPT` selects the standalone persona, and `DSH_CONTEXT_WINDOW` supplies fallback capacity for a model without exact catalog metadata. The SDK client's JSON-RPC `initialize` request is the sole runtime model selection. [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) may read `DSH_MODEL` only as the command's default `model` argument; an explicit `--model` needs no matching child environment value. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. @@ -22,7 +22,7 @@ The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bund The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. -The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes both tools. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index 73f6f16a51..696538381b 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -12,9 +12,9 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` ## 决策 -两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 +随附 Web minimal preset 对外提供持久 `bash` 与 `str_replace_editor`;独立 profile 在 Linux/macOS 上提供持久 `bash`,在 Windows 上提供 `pwsh`,并提供相同 editor。两者都不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。Web 宿主保留沙箱与批准服务;独立 profile 挂载 danger-full-access 沙箱策略,不挂载批准服务。两者都不贡献面向模型的策略上下文。 -独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 +独立的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)仍是 `dsh --profile sdk-minimal` 后面的完整 JSON-RPC 进程组合。它挂载 SDK 启动与 JSON-RPC 服务、按平台选择的持久 shell 所需的本地 PTY 和子进程服务、`fs-local`、该 shell 的工具消费方、editor,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 shell 消费该 profile 的 danger-full-access 沙箱策略;编辑器不受该策略限制。[独立 profile 决策](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)负责该组合包的位置及其与 `dsh-base` 的分离。 `DSH_SYSTEM_PROMPT` 选择独立组合的 persona,`DSH_CONTEXT_WINDOW` 为没有确切目录元数据的模型提供后备容量。SDK 客户端的 JSON-RPC `initialize` 请求是唯一运行时模型选择。[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)可以只把 `DSH_MODEL` 读作命令的默认 `model` 参数;显式 `--model` 不需要匹配的子进程环境值。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 @@ -22,7 +22,7 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 -SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行两个工具。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 397e960075..eb22fed232 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -946,9 +946,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], ['fs-local', '@deepseek-ai/dsh-fs-local'], ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 4ddf1a6015..10a8c18c63 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md -python-sdk.md: 24f5594a20eab6870d9725e0f1acfce5dff62f74 -python-sdk.zh.md: 47b420ab20df04d28e5498807dc73a425c7a9666 +python-sdk.md: 5fd8b35c08acdd0f0ff457547ca62b31e12994d5 +python-sdk.zh.md: 354d6829dc07056556d19ddfca68a95ad3a5b47f diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 24f5594a20..5fd8b35c08 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -8,7 +8,7 @@ This tutorial installs the published Python SDK, runs the shipped standalone min - Python 3.10 or newer - Git -- Linux x64, Linux arm64, or macOS 14 or newer on arm64 +- Linux x64, Linux arm64, macOS 14 or newer on arm64, or Windows x64 - A DeepSeek-compatible API endpoint and credential - An isolated workspace and an isolated Harness home @@ -92,13 +92,13 @@ Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or anothe |---|---| | System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` | | Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` | -| Model-facing tools | Persistent `bash` and `str_replace_editor` only | -| Bash timeout | 300 seconds | +| Model-facing tools | Persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor` | +| Shell timeout | 300 seconds | | Editor output limit | 16,000 characters | | Runtime context and compaction | Absent | | Session persistence | Uncompressed JSONL under `/sessions` | -The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so persistent Bash and the editor can modify any path visible to the runtime; use a disposable checkout or container. The PTY implementation makes this example POSIX-only. +The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while settings, managed credentials, telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. It pins `danger-full-access`, so the platform-selected persistent shell and editor can modify any path visible to the runtime; use a disposable checkout or container. The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 47b420ab20..354d6829dc 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -8,7 +8,7 @@ - Python 3.10 或更高版本 - Git -- Linux x64、Linux arm64,或 arm64 上的 macOS 14 或更高版本 +- Linux x64、Linux arm64、arm64 上的 macOS 14 或更高版本,或 Windows x64 - DeepSeek 兼容的 API endpoint 与凭据 - 隔离的 workspace 与隔离的 Harness home @@ -92,13 +92,13 @@ dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle |---|---| | 系统提示词 | `DSH_SYSTEM_PROMPT`,未设置时为 `You are a helpful software engineer assistant.` | | `minimal.py` 的模型 | `--model`,然后是 `DSH_MODEL`,最后是 `deepseek-v4-flash` | -| 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | -| Bash 超时 | 300 秒 | +| 面向模型的工具 | Linux/macOS 上的持久 `bash` 或 Windows 上的 `pwsh`,以及 `str_replace_editor` | +| Shell 超时 | 300 秒 | | Editor 输出上限 | 16,000 字符 | | 运行时上下文与 compaction | 不存在 | | 会话持久化 | `/sessions` 下的未压缩 JSONL | -该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此持久 Bash 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。由于采用 PTY 实现,本示例只支持 POSIX。 +该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;settings、托管凭据、遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 与 editor 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。 已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。 diff --git a/examples/python-sdk-agent/README.i18n.yaml b/examples/python-sdk-agent/README.i18n.yaml index e57c422380..07b10f117e 100644 --- a/examples/python-sdk-agent/README.i18n.yaml +++ b/examples/python-sdk-agent/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 examples/python-sdk-agent/README.md -README.md: 46a8dc2384d96db39841c4c1e4cdc88d82ff55eb -README.zh.md: 5e6e2f89f27398dd7404894f15391a6a693d689b +README.md: 7ec0ce984d20205fa50a3f12753a94f4209fac35 +README.zh.md: e46269f7d6a37123098747d2c8112aa365a98f01 diff --git a/examples/python-sdk-agent/README.md b/examples/python-sdk-agent/README.md index 46a8dc2384..7ec0ce984d 100644 --- a/examples/python-sdk-agent/README.md +++ b/examples/python-sdk-agent/README.md @@ -21,12 +21,12 @@ Set `DEEPSEEK_BASE_URL` for a compatible proxy, `DSH_MODEL` for the script's def The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../packages/bundle/sdk-minimal/README.md) is the complete explicit Cordis tree for this mode. It exposes exactly: -- owner-scoped persistent `bash` +- owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, local instruction discovery, compaction, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. -This variant is intentionally POSIX-only. Its persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. +The persistent PTY and editor can modify any path available to the runtime process, so use a disposable checkout or container. ## Add plugins diff --git a/examples/python-sdk-agent/README.zh.md b/examples/python-sdk-agent/README.zh.md index 5e6e2f89f2..e46269f7d6 100644 --- a/examples/python-sdk-agent/README.zh.md +++ b/examples/python-sdk-agent/README.zh.md @@ -21,12 +21,12 @@ python examples/python-sdk-agent/minimal.py \ 随附的 [`@deepseek-ai/dsh-sdk-minimal` 组合包](../../packages/bundle/sdk-minimal/README.zh.md)是该模式完整且显式的 Cordis 配置树。它只暴露: -- agent 所有的持久 `bash` +- Linux/macOS 上 agent 所有的持久 `bash`,或 Windows 上的 `pwsh` - 支持 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` 该组合包不包含 `dsh-base`,因此每一个新增配置项都是显式 profile 变更。运行时上下文、本地指令发现、compaction、settings、托管凭据、遥测、Web 工具、subagent 与完整默认工具清单均不存在。配置树保留 SDK 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、本地执行和 JSONL 持久化。 -此变体刻意只支持 POSIX。其持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 +持久 PTY 与 editor 可以修改运行时进程可访问的任何路径,因此只应在一次性 checkout 或容器中使用。 ## 添加插件 diff --git a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts index 230ada0336..d2e90a9de3 100644 --- a/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts +++ b/examples/python-sdk-agent/tests/keyless-smoke.e2e.ts @@ -242,7 +242,8 @@ describe('Python SDK dsh profile keyless smoke', () => { tools?: Array<{ function?: { name?: string } }> } expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) - expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual(['bash', 'str_replace_editor']) + const shellTool = process.platform === 'win32' ? 'pwsh' : 'bash' + expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual([shellTool, 'str_replace_editor'].sort()) const profile = JSON.parse( await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } } diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml index c573869472..0c606565e9 100644 --- a/packages/bundle/sdk-minimal/README.i18n.yaml +++ b/packages/bundle/sdk-minimal/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/bundle/sdk-minimal/README.md -README.md: b33ccb429ab0291d46f0271329b957f0ac7011fa -README.zh.md: 9e4ab381f5595630cbf139b1a1e8e30cef147f39 +README.md: 3c8d4efa7540e8ff317c897f2e2f463e448610bb +README.zh.md: 54a9d99c95322a34433aec81cb6d37cb16e5015e diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md index b33ccb429a..3c8d4efa75 100644 --- a/packages/bundle/sdk-minimal/README.md +++ b/packages/bundle/sdk-minimal/README.md @@ -2,19 +2,21 @@ English | [中文](README.zh.md) -Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its single insert is the complete Cordis tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a persistent Bash PTY, the string-replace editor, and uncompressed JSONL session persistence under `$DSH_HOME/sessions`. It deliberately does not include [`dsh-base`](../base/README.md), Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs tools, subagents, or any other model-facing tool. +Standalone minimal SDK application bundle for `dsh --profile sdk-minimal`. Its single insert is the complete Cordis tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell PTY, the string-replace editor, and uncompressed JSONL session persistence under `$DSH_HOME/sessions`. It deliberately does not include [`dsh-base`](../base/README.md), Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs tools, subagents, or any other model-facing tool. The profile remains part of the ordinary launcher and layering model. The bundle supplies the complete default tree; the profile patch, home patch, and ordered `--patch` files can replace rows or insert external bundles above it. `dsh plugin --profile sdk-minimal` manages persistent dependencies. The shipped template uses startup-only patches so one stdio connection never observes replacement of its server or agent dependencies. `DEEPSEEK_API_KEY` supplies the adapter credential. The SDK initialization request is the sole model selection; the adapter accepts that model id even when it is absent from its advisory catalog. `DSH_CONTEXT_WINDOW` sets the fallback capacity for such models, and `DSH_SYSTEM_PROMPT` replaces the default persona. The process working directory is the sandbox-policy workspace and local-filesystem root. The bundle sets `danger-full-access`; its persistent shell and editor can modify any path available to the process. +Exactly one persistent shell stack mounts by platform: Bash on Linux/macOS or PowerShell on Windows. Both use a 300-second timeout and one owner-scoped terminal; the other platform rows remain disabled. + ## Model Experience ### Minimal coding-agent composition #### What the model sees -The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` and `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. #### Token effect @@ -26,6 +28,5 @@ Stable for a fixed persona, platform, provider, model, and bundle patch stack. P ## Known Limitations and Deferred Work -- **The profile is POSIX-only** — this composition uses a Bash PTY; a Windows profile must select a PowerShell terminal and tool instead. - **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. - **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md index 9e4ab381f5..54a9d99c95 100644 --- a/packages/bundle/sdk-minimal/README.zh.md +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -2,19 +2,21 @@ [English](README.md) | 中文 -供 `dsh --profile sdk-minimal` 使用的独立极简 SDK 应用组合包。它的单个 insert 构成完整 Cordis 树:SDK stdio 启动与 JSON-RPC 对外服务、一个由环境配置的 DeepSeek 适配器、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、持久 Bash PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话持久化。它刻意不包含 [`dsh-base`](../base/README.zh.md)、Web、settings、托管凭据、遥测、压缩(compaction)、workspace 指令、skills、jobs 工具、subagent 或任何其他面向模型的工具。 +供 `dsh --profile sdk-minimal` 使用的独立极简 SDK 应用组合包。它的单个 insert 构成完整 Cordis 树:SDK stdio 启动与 JSON-RPC 对外服务、一个由环境配置的 DeepSeek 适配器、无执行器的 agent 主干、本地子进程与不受限文件系统提供方、按平台选择的持久 shell PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 会话持久化。它刻意不包含 [`dsh-base`](../base/README.zh.md)、Web、settings、托管凭据、遥测、压缩(compaction)、workspace 指令、skills、jobs 工具、subagent 或任何其他面向模型的工具。 该 profile 仍遵循普通 launcher 与分层模型。组合包提供完整默认树;profile patch、home patch 与有序 `--patch` 文件可以在其上替换配置项或插入外部组合包。`dsh plugin --profile sdk-minimal` 管理持久依赖。随附模板仅在启动时应用 patch,因此一个 stdio 连接不会观察到服务器或 agent 依赖在运行中被替换。 `DEEPSEEK_API_KEY` 提供适配器凭据。SDK 初始化请求是唯一模型选择;即使该模型 id 不在适配器的建议目录中,适配器也会接受它。`DSH_CONTEXT_WINDOW` 为这类模型设置后备容量,`DSH_SYSTEM_PROMPT` 替换默认 persona。进程工作目录同时作为沙箱策略 workspace 与本地文件系统根目录。该组合包设置 `danger-full-access`;其持久 shell 与编辑器可以修改进程可访问的任何路径。 +运行时会按平台恰好挂载一套持久 shell:Linux/macOS 使用 Bash,Windows 使用 PowerShell。两者都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。 + ## 模型体验 ### 极简 coding agent 组合 #### 模型看到的内容 -系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 agent 所有的持久 `bash` 与 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`,外加 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 #### Token 影响 @@ -26,6 +28,5 @@ ## 已知限制与待办工作 -- **该 profile 仅支持 POSIX** — 此组合使用 Bash PTY;Windows profile 必须改为选择 PowerShell 终端与工具。 - **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、权限策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 - **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 24d707c7e9..361e9a6f53 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -47,9 +47,17 @@ - id: terminal-bash name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 + - id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + # The editor uses the bare local filesystem; persistent Bash still consumes # the shared danger-full-access sandbox policy above. - id: fs-local @@ -71,6 +79,7 @@ - id: persistent-bash name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 description: |- @@ -83,6 +92,20 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. + - id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 1b3d5da5d2..42940a0ac8 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^" }, "peerDependencies": { diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index f8c23e92ac..7983a7793c 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -18,7 +18,7 @@ describe('dsh-sdk-minimal bundle', () => { const patches = yaml.load( readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }, - ) as Array<{ insert?: Array<{ id?: string; inject?: string[]; name?: string; config?: Record }> }> + ) as Array<{ insert?: Array<{ id?: string; inject?: string[]; name?: string; config?: Record; disabled?: unknown }> }> expect(patches).toHaveLength(1) const rows = patches[0]?.insert ?? [] expect(rows.map(row => [row.id, row.name])).toEqual([ @@ -33,9 +33,11 @@ describe('dsh-sdk-minimal bundle', () => { ['subprocess', '@deepseek-ai/dsh-subprocess-local'], ['pty', '@deepseek-ai/dsh-terminal'], ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], ['fs-local', '@deepseek-ai/dsh-fs-local'], ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], ]) @@ -57,6 +59,13 @@ describe('dsh-sdk-minimal bundle', () => { toolBash: false, toolJobs: false, }) + expect(rows.find(row => row.id === 'terminal-bash')).toMatchObject({ + disabled: { __jsExpr: "process.platform === 'win32'" }, + }) + expect(rows.find(row => row.id === 'terminal-pwsh')).toMatchObject({ + disabled: { __jsExpr: "process.platform !== 'win32'" }, + config: { shellDialect: 'pwsh', timeoutMs: 300000 }, + }) expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual( [...new Set(rows.map(row => row.name).filter((name): name is string => name !== undefined))].sort(), ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7f70486da..c13799de2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1591,6 +1591,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-str-replace-editor': specifier: workspace:^ version: link:../../fs/tool-str-replace-editor From d4a63abe859a39bf198cb402f8001c41ec92fbba Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:11:19 +0800 Subject: [PATCH 40/42] docs(python): define the Windows x64 runtime contract Record win-x64 as the sole Windows Python carrier: node24-win-x64 builds a py3-none-win_amd64 wheel with dsh.exe, rg.exe, and both ConPTY addons; Windows arm64 remains explicitly unsupported. The note also pins native build ownership, shell-free pnpm launch, installed-wheel keyless/live gates, and the PowerShell-specific minimal snapshot. Update the active SEA, sole-launcher, profile-runtime, installed-wheel, and publication decisions from three runtime wheels to four, preserving their existing rationale while linking the Windows extension. Contributor and runtime references now state the exact target, filenames, sidecars, snapshot ownership, and five-wheel release set in both languages. --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 4 +- ...-single-dsh-application-launcher.i18n.yaml | 4 +- ...6-08-22-single-dsh-application-launcher.md | 6 +-- ...8-22-single-dsh-application-launcher.zh.md | 6 +-- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- ...3-python-sdk-windows-x64-runtime.i18n.yaml | 6 +++ ...26-08-23-python-sdk-windows-x64-runtime.md | 49 +++++++++++++++++++ ...08-23-python-sdk-windows-x64-runtime.zh.md | 49 +++++++++++++++++++ ...8-11-python-publication-workflow.i18n.yaml | 4 +- .../2026-08-11-python-publication-workflow.md | 6 +-- ...26-08-11-python-publication-workflow.zh.md | 6 +-- ...talled-python-wheel-black-box-ci.i18n.yaml | 4 +- ...-23-installed-python-wheel-black-box-ci.md | 8 +-- ...-installed-python-wheel-black-box-ci.zh.md | 8 +-- python/development.i18n.yaml | 4 +- python/development.md | 8 +-- python/development.zh.md | 8 +-- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 4 +- python/sdk-runtime/README.zh.md | 4 +- 24 files changed, 156 insertions(+), 52 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 64c9153ca6..f03f7e81ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: e46dbbc119e2078e44632d81b333c8be5ab9d6d7 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7a1e3f445ea1c1f03df2b349a4391534a5502c3 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 8731528b9ae600bb8bfe12738669f3a84c11a06b +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 755b4bd7ddbe9b88b4f40b8a3ae7419f746b8dde diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index e46dbbc119..8731528b9a 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,13 +44,13 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all three targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index d7a1e3f445..755b4bd7dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,13 +44,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部三个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签,或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 61028c878a..f92d9862cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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/architecture/2026-08-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 48a45cb2454b5532a78474203b6d88aef3dd0697 -2026-08-22-single-dsh-application-launcher.zh.md: dbf2fb3a5bdc16208b0435482d8d0851bd7c44d7 +2026-08-22-single-dsh-application-launcher.md: 4640173068998d518f5cbdf537526ea479242b50 +2026-08-22-single-dsh-application-launcher.zh.md: 4f42652497b84a431a88442fe812045550bcb4ff diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 48a45cb245..4640173068 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -8,7 +8,7 @@ English | [中文](2026-08-22-single-dsh-application-launcher.zh.md) DeepSeek Harness application processes need one owner for composition, plugin resolution, environment discovery, shutdown, and user customization. A dedicated app bin with a complete `cordis.yml` creates a second lifecycle beside profile launch: plugins installed into a profile do not reach it, behavior drifts from `dsh-base`, and SDK callers learn arbitrary process argv instead of the product's composition model. -The Python SDK distributes a native executable and three platform wheels. Its packaged process must use the same profile launcher while preserving the closed VFS dependency tree, native sidecars, and installed-wheel evidence. +The Python SDK distributes a native executable through four platform wheels. Its packaged process uses the same profile launcher while preserving the closed VFS dependency tree, native sidecars, and installed-wheel evidence. ## Decision @@ -48,7 +48,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable Python example selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. -The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. +The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this launch, and the [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth carrier. ### Enforcement @@ -76,7 +76,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o **Hot-reload protocol profiles.** Rejected: replacing a protocol server or its dependencies can invalidate pending frames and SDK-owned agents. Process restart is the adoption boundary for SDK and ACP configuration changes. -**Move the Python executable through profiles without a separate packaging proof.** Rejected: the native VFS closure, three platform wheels, ripgrep and spawn-helper sidecars, default config discovery, and clean-install behavior require their own migration evidence. +**Move the Python executable through profiles without a separate packaging proof.** Rejected: the native VFS closure, four platform wheels, profile assets, ripgrep and spawn-helper sidecars, and clean-install behavior require their own migration evidence. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index dbf2fb3a5b..4f42652497 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -8,7 +8,7 @@ Status: implemented DeepSeek Harness 应用进程需要由同一个机制负责组合、插件解析、环境发现、关闭和用户自定义。带完整 `cordis.yml` 的专用应用 bin 会在 profile 启动之外形成第二套生命周期:安装到 profile 的插件无法到达它,行为会与 `dsh-base` 偏离,SDK 调用方还需要学习任意进程 argv,而不是产品的组合模型。 -Python SDK 分发一个原生可执行文件和三个平台 wheel 包。其打包进程必须使用同一 profile 启动器,同时保留封闭的 VFS 依赖树、原生伴随文件与 installed-wheel 证据。 +Python SDK 通过四个平台 wheel 包分发原生可执行文件。其打包进程使用同一 profile 启动器,同时保留封闭的 VFS 依赖树、原生伴随文件与 installed-wheel 证据。 ## Decision @@ -48,7 +48,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;可运行 Python 示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 -可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 +可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该启动方式,[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个载体。 ### 强制校验 @@ -76,7 +76,7 @@ Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest **热重载协议 profile。** 拒绝:替换协议服务器或其依赖可能破坏待处理协议帧与 SDK 自有 agent。进程重启是 SDK 与 ACP 配置变更的采用边界。 -**不做独立打包证明就把 Python 可执行文件迁移到 profile。** 拒绝:原生 VFS 闭包、三个平台 wheel 包、ripgrep 与 spawn-helper 伴随文件、默认配置发现和干净安装行为都需要自己的迁移证据。 +**不做独立打包证明就把 Python 可执行文件迁移到 profile。** 拒绝:原生 VFS 闭包、四个平台 wheel 包、profile 资源、ripgrep 与 spawn-helper 伴随文件和干净安装行为都需要自己的迁移证据。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index f418c62b83..add950844a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 3298224688e8f0cd4216f8ed68d9e4364014d2a9 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 400e70113e1112aa6e4979c64a5046199c07e8a0 +2026-08-23-python-sdk-dsh-profile-runtime.md: dcb4f77048d516f0e187b611dc09c224fba47b58 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 505b49506ae4c4be4809d588448b2c593d3ebaf5 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 3298224688..dcb4f77048 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -34,7 +34,7 @@ The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages ` Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. A complete matching generation returns without acquiring the cross-process writer lock. A missing or stale entry acquires the lock, rechecks the generation, and repairs it without exposing partial proxies; either carrier can replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. -The published target set is Linux x64, Linux arm64, and macOS arm64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. +The published target set is Linux x64, Linux arm64, macOS arm64, and Windows x64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth artifact and its platform-specific shell surface. ## Existing decisions and supersession diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 400e70113e..505b49506a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -34,7 +34,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。完整且匹配的 generation 不会获取跨进程写入锁。缺失或过期的配置项会获取该锁、重新检查 generation,并在不暴露半成品代理的前提下修复;任一载体都可以替换另一载体留下的受管配置项。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 -已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。 +已发布目标集合是 Linux x64、Linux arm64、macOS arm64 与 Windows x64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个产物及其平台专属 shell surface。 ## 既有决策与取代关系 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml new file mode 100644 index 0000000000..732ea6c39f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/architecture/2026-08-23-python-sdk-windows-x64-runtime.md +2026-08-23-python-sdk-windows-x64-runtime.md: 57c3ac66517d62528464521ba37e9c644899d1ca +2026-08-23-python-sdk-windows-x64-runtime.zh.md: e50efac91bed33f6559386ebdbb3deaa52c9d3ca diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md new file mode 100644 index 0000000000..57c3ac6651 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md @@ -0,0 +1,49 @@ +# Agent Note: Python SDK Windows x64 runtime + +Status: implemented + +English | [中文](2026-08-23-python-sdk-windows-x64-runtime.zh.md) + +## Problem + +The Python SDK runtime distribution needs a Windows carrier without creating another application entrypoint or weakening the installed-wheel evidence used by the existing native targets. Windows executable names, Python wheel tags, ConPTY addons, ripgrep sidecars, shell composition, virtual environments, and process launch rules differ from Linux and macOS. Claiming Windows from cross-platform unit tests or from a non-Windows executable would leave the artifact selected by `pip` unproved. + +## Decision + +### One x64 product + +`python/sdk-runtime/platforms.json` declares one Windows target, `win-x64`. Its pkg target is `node24-win-x64`, its runtime wheel tag is `py3-none-win_amd64`, and its payload is `deepseek-harness-sdk-runtime-win-x64.exe` with `deepseek-harness-sdk-runtime-win-x64-rg.exe`. The packaged `node-pty` tree must contain both x64 ConPTY addons. Runtime lookup rejects Windows arm64 rather than selecting or relabeling the x64 wheel. + +The Python process still launches the ordinary `dsh --profile sdk` application and requires an explicit Harness home under the [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md). Windows adds no Python-specific Node application, complete-config entrypoint, implicit `~/.dsh`, or system Node requirement. + +### Native build and publication + +The executable builder accepts `win` as a pkg platform only with x64, requires the Windows build to run under x64 Node on a Windows host, preserves `.exe` names, and copies `@vscode/ripgrep-win32-x64` as the conventional `-rg.exe` sidecar. Pnpm subprocesses use a caller-supplied JavaScript entry through `process.execPath`. When the caller exposes a `.cmd` shim, the builder resolves the installed `pnpm.mjs` or `pnpm.cjs` through `PNPM_HOME`; it fails if no JavaScript entry exists instead of spawning the shim or enabling a command shell. + +The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside the three existing targets. The public GitHub release and GitLab tag pipeline each publish the same four runtime wheels plus the pure SDK wheel. Windows arm64 is absent from target parsing, manifests, matrices, release contents, and documentation. + +### Installed-wheel behavior + +The Windows lane creates a clean Windows virtual environment, installs the exact SDK and `win_amd64` runtime wheels, changes to a directory outside the checkout, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and runs the same `--scenario all --installed-wheel` blackbox as every other target. Trusted pull requests also run the same two-turn `sdk-live` provider scenario. Fork and Dependabot heads receive no key. + +After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. + +The minimal blackbox uses persistent `pwsh` plus `str_replace_editor` on Windows and owns `minimal/win-x64/model-visible.json`; Linux and macOS retain persistent Bash and the shared `minimal/model-visible.json`. The advanced process/subagent snapshot and restart/durable-log snapshot remain shared across all targets. The shipped [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) selects the same platform shell pair for the runnable Python tutorial. + +## Existing decisions and supersession + +This decision partially supersedes the Windows non-goal in the [single-file runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md) and extends the required target set in the [installed Python wheel blackbox decision](../testing/2026-08-23-installed-python-wheel-black-box-ci.md). Those notes remain authoritative for SEA packaging, the two Python distributions, provenance checks, key handling, and the common blackbox scenarios. + +## Alternatives considered + +**Add Windows before the dsh profile runtime.** Rejected because tests for the retired private direct-config carrier would not prove the Windows form users receive. Windows is defined only for the sole `dsh` launch architecture. + +**Publish Windows arm64 too.** Rejected because the accepted product scope is x64 only; adding a second architecture would require its own native builder, wheel tag, ConPTY and ripgrep payload checks, installed-wheel matrix leg, and release artifact. + +**Give Windows a smaller smoke suite.** Rejected because a platform wheel cannot borrow protocol, persistence, worker, MCP, plugin, native-tool, or real-provider evidence from another executable. Platform-specific expected output is limited to the persistent shell surface; the remaining snapshots stay shared. + +**Run Windows commands through PowerShell workflow steps only.** Rejected for the reusable build body because it would duplicate the Linux/macOS installation and blackbox sequence. Git Bash supplies the common workflow grammar; only virtual-environment executable selection and the product payload names differ. + +## Consequences + +Python installation now selects a Node-free Windows x64 runtime with the same explicit-home and profile customization model as Linux and macOS. Every pull request pays for a fourth executable, runtime wheel, full keyless blackbox, and—on trusted heads—real provider task. Release validation retains five wheels instead of four. Windows arm64 users receive an explicit unsupported-platform failure until a separate native product decision supplies and proves that carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md new file mode 100644 index 0000000000..e50efac91b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Python SDK Windows x64 运行时 + +Status: implemented + +[English](2026-08-23-python-sdk-windows-x64-runtime.md) | 中文 + +## Problem + +Python SDK 运行时分发需要 Windows 载体,同时不能创建另一个应用入口,也不能削弱现有原生目标所使用的 installed-wheel 证据。Windows 的可执行文件名、Python wheel 标签、ConPTY addon、ripgrep sidecar、shell 组合、虚拟环境与进程启动规则均不同于 Linux 和 macOS。仅凭跨平台单元测试或非 Windows 可执行文件声称支持 Windows,会使 `pip` 实际选择的产物未经证明。 + +## Decision + +### 唯一 x64 产品 + +`python/sdk-runtime/platforms.json` 声明唯一的 Windows 目标 `win-x64`。其 pkg 目标是 `node24-win-x64`,运行时 wheel 标签是 `py3-none-win_amd64`,载荷包含 `deepseek-harness-sdk-runtime-win-x64.exe` 与 `deepseek-harness-sdk-runtime-win-x64-rg.exe`。打包后的 `node-pty` 文件树必须包含两个 x64 ConPTY addon。运行时查找会拒绝 Windows arm64,不会选择 x64 wheel 或把它重新标记为 arm64。 + +Python 进程仍按 [Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)启动普通 `dsh --profile sdk` 应用,并要求显式 Harness home。Windows 不会增加 Python 专用 Node 应用、完整配置入口、隐式 `~/.dsh` 或系统 Node 要求。 + +### 原生构建与发布 + +可执行文件构建器仅允许 x64 使用 pkg 的 `win` 平台,并要求 Windows 构建在 Windows 宿主的 x64 Node 下运行;构建器保留 `.exe` 文件名,并把 `@vscode/ripgrep-win32-x64` 复制为常规 `-rg.exe` sidecar。Pnpm 子进程通过 `process.execPath` 执行调用方提供的 JavaScript 入口。当调用方暴露 `.cmd` shim 时,构建器会通过 `PNPM_HOME` 解析已安装的 `pnpm.mjs` 或 `pnpm.cjs`;如果不存在 JavaScript 入口,构建会失败,而不会启动 shim 或启用命令 shell。 + +必需 GitHub 矩阵会在 `windows-2025` 上构建 `node24-win-x64`,与现有三个目标并列。公开 GitHub 发布与 GitLab 标签流水线都会发布同一组四个运行时 wheel 加纯 SDK wheel。目标解析、manifest、矩阵、发布内容与文档均不包含 Windows arm64。 + +### Installed-wheel 行为 + +Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配的 SDK 与 `win_amd64` 运行时 wheel,切换到 checkout 外的目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,再运行与其他目标相同的 `--scenario all --installed-wheel` 黑盒测试。可信拉取请求还会运行相同的双轮 `sdk-live` 真实提供方场景。Fork 与 Dependabot head 不会获得密钥。 + +成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 + +极简黑盒测试在 Windows 上使用持久 `pwsh` 与 `str_replace_editor`,并由 `minimal/win-x64/model-visible.json` 固定预期;Linux 与 macOS 保留持久 Bash 和共享的 `minimal/model-visible.json`。高级进程/subagent 快照与重启/持久日志快照继续由所有目标共享。随附的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)为可运行 Python 教程选择同一组平台 shell。 + +## Existing decisions and supersession + +本决策部分取代[单文件运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的 Windows 非目标声明,并扩展[安装后 Python wheel 黑盒决策](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)中的必需目标集合。上述 Note 继续负责 SEA 打包、两个 Python distribution、来源校验、密钥处理与通用黑盒场景。 + +## Alternatives considered + +**在 dsh profile 运行时之前增加 Windows。** 否决:针对已退役私有直启载体的测试无法证明 Windows 用户实际获得的形态。Windows 仅定义于唯一的 `dsh` 启动架构。 + +**同时发布 Windows arm64。** 否决:已接受的产品范围只有 x64;增加第二种架构需要独立的原生构建器、wheel 标签、ConPTY 与 ripgrep 载荷校验、installed-wheel 矩阵 lane 及发布产物。 + +**为 Windows 提供较小的冒烟测试套件。** 否决:一个平台 wheel 不能借用其他可执行文件的协议、持久化、worker、MCP、插件、原生工具或真实提供方证据。只有持久 shell surface 使用平台专属预期,其余快照继续共享。 + +**只通过 PowerShell workflow 步骤运行 Windows 命令。** 否决:这会在可复用构建主体中复制 Linux/macOS 的安装与黑盒测试序列。Git Bash 提供通用 workflow 语法;只有虚拟环境可执行程序选择与产品载荷名称因平台而异。 + +## Consequences + +Python 安装现在会选择无需 Node 的 Windows x64 运行时,并与 Linux、macOS 使用同一套显式 home 与 profile 自定义模型。每个拉取请求都要承担第四个可执行文件、运行时 wheel 与完整 keyless 黑盒测试;可信 head 还要承担真实提供方任务。候选发行版验证会保留五个而不是四个 wheel。Windows arm64 用户会收到明确的不支持平台错误,直到另一项原生产品决策提供并证明该载体。 diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml index b454ac5682..91997d47b8 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.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/process/2026-08-11-python-publication-workflow.md -2026-08-11-python-publication-workflow.md: db346dfb96d1657e732c72a3f7a3ca74f92a947a -2026-08-11-python-publication-workflow.zh.md: 17b9b14dd16d85301796a38bb64c464c94a8ab9a +2026-08-11-python-publication-workflow.md: 282bd453013da9b745c601f7b1f4be2cbd133629 +2026-08-11-python-publication-workflow.zh.md: 279dc4b5798d5ceb5968f92c58a7f57c4f2c5cdd diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md index db346dfb96..282bd45301 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md @@ -6,15 +6,15 @@ English | [中文](2026-08-11-python-publication-workflow.zh.md) ## Problem -The Python SDK comprises one platform-independent client wheel and three native runtime wheels that must carry one version and become installable as a set. Public PyPI uploads expose package metadata and files immediately, cannot replace an uploaded filename, and create a temporarily unusable SDK if its exact runtime dependency has not arrived. The private repository needs to exercise the complete native build and validation sequence without publishing any artifact externally. +The Python SDK comprises one platform-independent client wheel and four native runtime wheels that must carry one version and become installable as a set. Public PyPI uploads expose package metadata and files immediately, cannot replace an uploaded filename, and create a temporarily unusable SDK if its exact runtime dependency has not arrived. The private repository needs to exercise the complete native build and validation sequence without publishing any artifact externally. ## Decision -The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all three platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the four resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. +The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all four platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the five resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. A run with `publish=true` must use the `python-v` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token. -Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all three platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files. +Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all four platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files. Both publication actions disable public attestations. The action still uses Trusted Publishing for authentication, while omitting provenance that would disclose the private publisher repository instead of the public source mirror. diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md index 17b9b14dd1..279dc4b579 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -Python SDK 由一个平台无关的客户端 wheel 包和三个原生运行时 wheel 包组成,它们必须使用同一版本,并作为一组可安装。public PyPI 上传会立即公开包元数据和文件,无法替换已上传的同名文件;如果精确版本的运行时依赖尚未到达,还会产生暂时不可用的 SDK。私有仓库需要在不向外发布任何产物的情况下,执行完整的原生构建与验证流程。 +Python SDK 由一个平台无关的客户端 wheel 包和四个原生运行时 wheel 包组成,它们必须使用同一版本,并作为一组可安装。public PyPI 上传会立即公开包元数据和文件,无法替换已上传的同名文件;如果精确版本的运行时依赖尚未到达,还会产生暂时不可用的 SDK。私有仓库需要在不向外发布任何产物的情况下,执行完整的原生构建与验证流程。 ## 决策 -GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部三个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得四份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 +GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部四个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得五份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。 -发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部三个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。 +发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部四个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。 两个发布 action 都会禁用公开 attestation。action 仍使用 Trusted Publishing 进行身份认证,同时不上传会披露私有发布仓库而非公开源码镜像的 provenance。 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml index 635b5df7af..03c1a43a28 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.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/testing/2026-08-23-installed-python-wheel-black-box-ci.md -2026-08-23-installed-python-wheel-black-box-ci.md: a2fd4134d7bff0e74aa2d1afc3590e9cdd90809e -2026-08-23-installed-python-wheel-black-box-ci.zh.md: 203440ed0f6bbe84257474dd69400a64bc480cd3 +2026-08-23-installed-python-wheel-black-box-ci.md: 0ac3bc63ef391536a761ad6db9d0854a3beebe01 +2026-08-23-installed-python-wheel-black-box-ci.zh.md: 365da458d3eb33dbc82dcdafaebea593cc4fe971 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md index a2fd4134d7..0ac3bc63ef 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -24,13 +24,13 @@ Linux additionally retains its manylinux 2.28 clean-install smoke and GLIBC chec ### Real DeepSeek API -Trusted pull requests run a second installed-wheel check on every native target with `DEEPSEEK_API_KEY_EXTERNAL`, mapped only into a preflight and the live test step. The preflight fails when the secret is empty, so the provider suite cannot self-skip to green. The test starts the public SDK against `https://api.deepseek.com`, asks the model to write an exact sentinel file through Bash, asks a second turn in the same session to read it, and verifies the external bytes, final responses, completed turn reasons, model-requested tool calls, and the existence and Zstandard framing of its session log. Decoded record content and completed-turn durability are deterministic keyless obligations owned by the restart snapshot rather than inferred from compressed live-provider bytes. +Trusted pull requests run a second installed-wheel check on every native target with `DEEPSEEK_API_KEY_EXTERNAL`, mapped only into a preflight and the live test step. The preflight fails when the secret is empty, so the provider suite cannot self-skip to green. The test starts the public SDK against `https://api.deepseek.com`, asks the model to write an exact sentinel file through the platform shell, asks a second turn in the same session to read it, and verifies the external line content, final responses, completed turn reasons, model-requested tool calls, and the existence and Zstandard framing of its session log. Decoded record content and completed-turn durability are deterministic keyless obligations owned by the restart snapshot rather than inferred from compressed live-provider bytes. Fork and Dependabot pull requests never receive the repository secret. Their native jobs run the complete keyless path and skip both secret-bearing steps; `pull_request_target` is forbidden because it would execute untrusted code with the key. ### Required targets -The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, and macOS arm64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. Windows has no runtime wheel in the platform manifest and is not claimed by this decision. +The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, macOS arm64, and Windows x64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [Windows x64 runtime decision](../architecture/2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and its PowerShell-specific minimal snapshot. ## Existing decisions and supersession @@ -38,7 +38,7 @@ This decision supersedes the single-target topology in the archived [required Py ## Alternatives considered -**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the three published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. +**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the four published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. **Run full behavior before wheel construction and keep two small installed smokes.** Rejected because that proves the executable against source imports, then proves too little through the distribution users install. The clean installed environment is the stronger common location for the same scenarios. @@ -48,4 +48,4 @@ This decision supersedes the single-target topology in the archived [required Py ## Consequences -Every pull request pays for three native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. +Every pull request pays for four native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md index 203440ed0f..365da458d3 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -24,13 +24,13 @@ Linux 另外保留 manylinux 2.28 干净安装冒烟测试与 GLIBC 检查。mac ### 真实 DeepSeek API -可信拉取请求会在每个原生目标上运行第二项安装后 wheel 检查,并且只在预检与 live 测试步骤中把 `DEEPSEEK_API_KEY_EXTERNAL` 映射进去。密钥为空时预检失败,因此提供方测试不能通过自行 skip 产生假绿。该测试通过公开 SDK 访问 `https://api.deepseek.com`,要求模型通过 Bash 写入内容精确的 sentinel 文件,再在同一 session 的第二个轮次中读取它,并校验外部文件字节、最终响应、已完成的轮次结束原因、模型请求的工具调用,以及 session 日志存在且采用 Zstandard framing。解码后的记录内容与已完成轮次的持久性是由 restart 快照负责的确定性 keyless 要求,不从压缩后的 live 提供方字节推断。 +可信拉取请求会在每个原生目标上运行第二项安装后 wheel 检查,并且只在预检与 live 测试步骤中把 `DEEPSEEK_API_KEY_EXTERNAL` 映射进去。密钥为空时预检失败,因此提供方测试不能通过自行 skip 产生假绿。该测试通过公开 SDK 访问 `https://api.deepseek.com`,要求模型通过当前平台 shell 写入内容精确的 sentinel 文件,再在同一 session 的第二个轮次中读取它,并校验外部文件行内容、最终响应、已完成的轮次结束原因、模型请求的工具调用,以及 session 日志存在且采用 Zstandard framing。解码后的记录内容与已完成轮次的持久性是由 restart 快照负责的确定性 keyless 要求,不从压缩后的 live 提供方字节推断。 Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 job 运行完整 keyless 路径并跳过两个带密钥的步骤;禁止使用 `pull_request_target`,因为它会让不可信代码带着密钥执行。 ### 必需目标 -拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64 与 macOS arm64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。Windows 不在运行时平台 manifest 中,本决策不声称支持它。 +拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64、macOS arm64 与 Windows x64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[Windows x64 运行时决策](../architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及其 PowerShell 专属极简快照。 ## Existing decisions and supersession @@ -38,7 +38,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Alternatives considered -**只保留 Linux x64 必需载体。** 否决:三个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 +**只保留 Linux x64 必需载体。** 否决:四个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 **在 wheel 构建前运行完整行为,并保留两个很小的安装后冒烟测试。** 否决:这只能证明可执行文件配合源码 import 工作,再通过 distribution 证明很少的行为。干净安装环境是在同一批场景中验证用户实际安装内容的更强位置。 @@ -48,4 +48,4 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Consequences -每个拉取请求都会承担三个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 +每个拉取请求都会承担四个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index a372e966ac..d9f2580549 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 61094a277d2b91063a0d368ec30f444eeb132128 -development.zh.md: a73de6e091050cebb0b26037a7cca3adc814d961 +development.md: f0d448cf4c4ce21895b3f8b0cf43db7ab052caea +development.zh.md: 74e2a6a83ca5ff5ac5b820ed6fdc8d72c0a48798 diff --git a/python/development.md b/python/development.md index 61094a277d..f0d448cf4c 100644 --- a/python/development.md +++ b/python/development.md @@ -13,7 +13,7 @@ pnpm install pnpm exec tsx scripts/build-exe-for-python-sdk.ts ``` -Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64` to select platforms. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. macOS builds also sync the matching spawn helper required by `node-pty`. +Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64` to select platforms. Build each target on its native architecture. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. Windows emits `.exe` and `-rg.exe`; macOS also syncs the matching spawn helper required by `node-pty`. ## Validate the SDK @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the shipped `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job. `advanced/` pins one complex process's SDK result and parent/child session logs. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. +Three scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the Linux/macOS `sdk-minimal` profile's assembled system prompts, advertised tool schemas, and model-visible messages; `minimal/win-x64/model-visible.json` pins its PowerShell counterpart. A plugin that contributes an unintended system section or user message therefore fails the job, and every message the profile emits is compared. `advanced/` pins one complex process's SDK result and parent/child session logs across every target. `restart/` launches two complete SDK runtime processes against one persistence root and snapshots their isolated model histories, high-level results, and separate durable logs across every target. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. Trusted pull requests also run `--scenario sdk-live --installed-wheel` on every native target. That scenario performs two tool-using turns against `https://api.deepseek.com`, verifies the created file externally, and fails when the repository secret is absent instead of self-skipping. Fork and Dependabot pull requests run the complete keyless installed-wheel path but receive no key. @@ -79,11 +79,11 @@ pip install \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` -The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS 14 or newer on arm64. A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. +The runtime distribution is wheel-only. The release pipeline publishes four platform wheels with the pure SDK wheel: Linux x64, Linux arm64, macOS 14 or newer on arm64, and Windows x64 (`win_amd64`). A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. ## Validate a release candidate -Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry run cannot enter either publication job. +Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all five wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry run cannot enter either publication job. Public publication runs from the private automation repository; package metadata points to the separate read-only public source mirror, which does not run release Actions. The private repository defines the repository variable `PYPI_PUBLISHER_REPOSITORY` as its own `owner/name` and keeps `PUBLIC_PYPI_RELEASE_ENABLED=false` except during an intentional release. diff --git a/python/development.zh.md b/python/development.zh.md index a73de6e091..74e2a6a83c 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -13,7 +13,7 @@ pnpm install pnpm exec tsx scripts/build-exe-for-python-sdk.ts ``` -所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64`。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。macOS 构建还会同步 `node-pty` 所需的配套 spawn 辅助程序。 +所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64`。每个目标都应在其原生架构上构建。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。Windows 会生成 `.exe` 与 `-rg.exe`;macOS 构建还会同步 `node-pty` 所需的配套 spawn 辅助程序。 ## 验证 SDK @@ -34,7 +34,7 @@ uv run --project python/sdk python scripts/smoke-python-runtime.py \ --scenario sdk-minimal --exe dist-exe/deepseek-harness-sdk-runtime-macos-arm64 ``` -其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定随附 `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败。`advanced/` 固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 +其中三个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定 Linux/macOS `sdk-minimal` profile 所组装的系统提示词、对外公布的工具 schema 与模型可见消息;`minimal/win-x64/model-visible.json` 固定对应的 PowerShell 版本。因此,插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败,且该 profile 发出的每条消息都会参与比对。`advanced/` 跨所有目标固定一个复杂进程的 SDK 结果及父/子会话日志。`restart/` 针对同一持久化根目录启动两个完整 SDK 运行时进程,并跨所有目标固定其彼此隔离的模型历史、高层结果与独立持久日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 可信拉取请求还会在每个原生目标上运行 `--scenario sdk-live --installed-wheel`。该场景面向 `https://api.deepseek.com` 执行两个使用工具的轮次,从外部验证已创建文件,并在仓库密钥缺失时失败而不是自行 skip。Fork 与 Dependabot 拉取请求会运行完整的 keyless 安装后 wheel 路径,但不会获得密钥。 @@ -79,11 +79,11 @@ pip install \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` -运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS 14 或更高版本的 arm64。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 +运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布四个平台 wheel 包:Linux x64、Linux arm64、macOS 14 或更高版本的 arm64,以及 Windows x64(`win_amd64`)。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 ## 验证候选发行版 -手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部四个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。该运行没有注册表凭据,dry-run 运行无法进入任何发布作业。 +手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部五个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。该运行没有注册表凭据,dry-run 运行无法进入任何发布作业。 公开发布从私有自动化仓库运行;包元数据指向独立的只读公开源码镜像,该镜像不运行发布 Actions。私有仓库把仓库变量 `PYPI_PUBLISHER_REPOSITORY` 定义为自身的 `owner/name`,并且只在有意发布期间把 `PUBLIC_PYPI_RELEASE_ENABLED` 从 `false` 改为 `true`。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 51965002ce..044ba3a728 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: 1552f2a120938ecab6d05dd244a745bec65bb00a -README.zh.md: 9524617ee1a950080476a91db5ec6e14727518ce +README.md: 28695259928a7edc6e6cf67e737f1012729df5a4 +README.zh.md: f23b253cfe47d9f1ae24568b51d9db810c7a4a9f diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 1552f2a120..2869525992 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Platform runtime wheel for the DeepSeek Harness Python SDK. It packages the norm The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Python module. `dsh` forwards its arguments to the bundled executable and requires a non-empty `DSH_HOME`; it never falls back to `~/.dsh`. -Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory. Linux and macOS wheels include a target-native `-rg` sidecar; macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, and macOS arm64. The wheel tag and payload must match exactly. +Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published. Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. @@ -25,7 +25,7 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr ## Packaged profile resolution -`dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. +`dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries and Windows ConPTY addons are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. External profile management uses `dsh plugin --profile ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 9524617ee1..f23b253cfe 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ DeepSeek Harness Python SDK 的平台运行时 wheel。它把普通 `dsh` CLI Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模块。`dsh` 将参数转发给内置可执行程序,并要求非空 `DSH_HOME`;它不会回退到 `~/.dsh`。 -生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序;macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64 与 macOS arm64。Wheel tag 必须与载荷严格匹配。 +生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64 与 Windows x64。Wheel tag 必须与载荷严格匹配;不发布 Windows arm64 wheel。 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 与 sdist 均不包含它。 @@ -25,7 +25,7 @@ Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模 ## 打包后的 profile 解析 -`dsh` 在显式 home 下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理镜像显式运行时 exports、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与原生 addon 一同打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 +`dsh` 在显式 home 下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理镜像显式运行时 exports、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与 Windows ConPTY addon 会同其他原生 addon 一起打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 外部 profile 管理使用 `dsh plugin --profile ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。 From 8101a0d097049639944fb39662f07fbd1c5a4794 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:18:59 +0800 Subject: [PATCH 41/42] fix(python): make Windows release paths native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the GitHub Windows runtime leg under the runner’s native PowerShell instead of inheriting the POSIX Bash body. POSIX and Windows now own explicit output resolution, virtual-environment setup, environment scrubbing, and keyless/live black-box commands, while portable build commands continue to use each runner’s default shell. Put the pinned uv installation on the GitLab Windows job PATH before either the smoke or release builder invokes it. Reject a runtime executable whose basename does not match the selected platform manifest, and reject Intel macOS at platform selection instead of reporting a misleading missing artifact. Add a complete PowerShell path to the published Python tutorial and record the three-phase shutdown-time bound in the Windows runtime decision. Workflow, Python, and bilingual documentation tests pin the resulting behavior. --- ...3-python-sdk-windows-x64-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-windows-x64-runtime.md | 4 +- ...08-23-python-sdk-windows-x64-runtime.zh.md | 4 +- .../workflows/build-exe-for-python-sdk.yml | 145 +++++++++++++----- .gitlab-ci.yml | 1 + docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 43 ++++++ docs/user/guide/python-sdk.zh.md | 43 ++++++ .../src/deepseek_harness_runtime/__init__.py | 7 +- python/sdk/tests/test_release_version.py | 13 ++ python/sdk/tests/test_runtime_resolution.py | 8 + scripts/build-python-release.py | 4 + scripts/ci-workflow.spec.ts | 57 ++++--- 13 files changed, 270 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml index 732ea6c39f..0da9832af7 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.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/architecture/2026-08-23-python-sdk-windows-x64-runtime.md -2026-08-23-python-sdk-windows-x64-runtime.md: 57c3ac66517d62528464521ba37e9c644899d1ca -2026-08-23-python-sdk-windows-x64-runtime.zh.md: e50efac91bed33f6559386ebdbb3deaa52c9d3ca +2026-08-23-python-sdk-windows-x64-runtime.md: b4ba54d9bd8e7a2eaa9277116a7868a1942d0531 +2026-08-23-python-sdk-windows-x64-runtime.zh.md: 6f05817b647a152cec626f09866f415d164064ec diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md index 57c3ac6651..b4ba54d9bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md @@ -26,7 +26,7 @@ The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside the The Windows lane creates a clean Windows virtual environment, installs the exact SDK and `win_amd64` runtime wheels, changes to a directory outside the checkout, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and runs the same `--scenario all --installed-wheel` blackbox as every other target. Trusted pull requests also run the same two-turn `sdk-live` provider scenario. Fork and Dependabot heads receive no key. -After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. +After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. `shutdown_timeout_seconds` bounds each of the shutdown request, EOF grace, and termination-confirmation phases, so a pathological close can approach three times that value before the final kill. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. The minimal blackbox uses persistent `pwsh` plus `str_replace_editor` on Windows and owns `minimal/win-x64/model-visible.json`; Linux and macOS retain persistent Bash and the shared `minimal/model-visible.json`. The advanced process/subagent snapshot and restart/durable-log snapshot remain shared across all targets. The shipped [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) selects the same platform shell pair for the runnable Python tutorial. @@ -42,7 +42,7 @@ This decision partially supersedes the Windows non-goal in the [single-file runt **Give Windows a smaller smoke suite.** Rejected because a platform wheel cannot borrow protocol, persistence, worker, MCP, plugin, native-tool, or real-provider evidence from another executable. Platform-specific expected output is limited to the persistent shell surface; the remaining snapshots stay shared. -**Run Windows commands through PowerShell workflow steps only.** Rejected for the reusable build body because it would duplicate the Linux/macOS installation and blackbox sequence. Git Bash supplies the common workflow grammar; only virtual-environment executable selection and the product payload names differ. +**Run the Windows leg through Git Bash.** Rejected because the repository requires native `pwsh` on Windows runners and MSYS path conversion would not prove native command behavior. Portable one-line steps use each runner's default shell; path, virtual-environment, and blackbox steps have explicit POSIX and PowerShell forms. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md index e50efac91b..6f05817b64 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md @@ -26,7 +26,7 @@ Python 进程仍按 [Python profile 运行时决策](2026-08-23-python-sdk-dsh-p Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配的 SDK 与 `win_amd64` 运行时 wheel,切换到 checkout 外的目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,再运行与其他目标相同的 `--scenario all --installed-wheel` 黑盒测试。可信拉取请求还会运行相同的双轮 `sdk-live` 真实提供方场景。Fork 与 Dependabot head 不会获得密钥。 -成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 +成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。`shutdown_timeout_seconds` 会分别限制 shutdown 请求、EOF 宽限与终止确认阶段,因此异常关闭在最终 kill 前可能接近该值的三倍。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 极简黑盒测试在 Windows 上使用持久 `pwsh` 与 `str_replace_editor`,并由 `minimal/win-x64/model-visible.json` 固定预期;Linux 与 macOS 保留持久 Bash 和共享的 `minimal/model-visible.json`。高级进程/subagent 快照与重启/持久日志快照继续由所有目标共享。随附的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)为可运行 Python 教程选择同一组平台 shell。 @@ -42,7 +42,7 @@ Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配 **为 Windows 提供较小的冒烟测试套件。** 否决:一个平台 wheel 不能借用其他可执行文件的协议、持久化、worker、MCP、插件、原生工具或真实提供方证据。只有持久 shell surface 使用平台专属预期,其余快照继续共享。 -**只通过 PowerShell workflow 步骤运行 Windows 命令。** 否决:这会在可复用构建主体中复制 Linux/macOS 的安装与黑盒测试序列。Git Bash 提供通用 workflow 语法;只有虚拟环境可执行程序选择与产品载荷名称因平台而异。 +**通过 Git Bash 运行 Windows lane。** 否决:仓库要求 Windows runner 使用原生 `pwsh`,而 MSYS 路径转换无法证明原生命令行为。可移植的单行步骤使用各 runner 的默认 shell;路径、虚拟环境与黑盒步骤分别提供显式 POSIX 和 PowerShell 形式。 ## Consequences diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 056b7a2a2c..0a3dc200f2 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -156,9 +156,6 @@ jobs: fail-fast: false matrix: include: ${{ fromJSON(needs.plan.outputs.matrix) }} - defaults: - run: - shell: bash steps: - uses: actions/checkout@v6 @@ -241,8 +238,9 @@ jobs: DSH_BUILD_CLIENT_PROFILE: official run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} - - name: Resolve platform outputs - id: runtime + - name: Resolve platform outputs (POSIX) + id: runtime-posix + if: runner.os != 'Windows' env: TARGET: ${{ matrix.target }} VERSION: ${{ needs.plan.outputs.version }} @@ -254,27 +252,36 @@ jobs: linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl ;; - win-x64) - exe="$exe.exe" - wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-win_amd64.whl - ;; *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; esac - if [ "$RUNNER_OS" = Windows ]; then - [ -f "$exe" ] || { echo "::error::$exe missing"; exit 1; } - else - [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } - fi + [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } echo "platform=$platform" >> "$GITHUB_OUTPUT" echo "exe=$exe" >> "$GITHUB_OUTPUT" echo "wheel=$wheel" >> "$GITHUB_OUTPUT" + - name: Resolve platform outputs (Windows) + id: runtime-windows + if: runner.os == 'Windows' + shell: pwsh + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ needs.plan.outputs.version }} + run: | + if ($env:TARGET -ne 'node24-win-x64') { throw "Unsupported runtime target $env:TARGET" } + $platform = 'win-x64' + $exe = Join-Path $PWD 'dist-exe\deepseek-harness-sdk-runtime-win-x64.exe' + $wheel = "deepseek_harness_runtime_bin-$env:VERSION-py3-none-win_amd64.whl" + if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw "Runtime executable is missing at $exe" } + "platform=$platform" >> $env:GITHUB_OUTPUT + "exe=$exe" >> $env:GITHUB_OUTPUT + "wheel=$wheel" >> $env:GITHUB_OUTPUT + - name: Build release-shaped runtime wheel run: >- python scripts/build-python-release.py --package runtime - --platform "${{ steps.runtime.outputs.platform }}" - --runtime-exe "${{ steps.runtime.outputs.exe }}" + --platform "${{ steps.runtime-posix.outputs.platform || steps.runtime-windows.outputs.platform }}" + --runtime-exe "${{ steps.runtime-posix.outputs.exe || steps.runtime-windows.outputs.exe }}" --output-dir dist-python - uses: actions/download-artifact@v8 @@ -282,40 +289,68 @@ jobs: name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - - name: Install local SDK and runtime wheels into a clean venv - id: smoke-venv + - name: Install local SDK and runtime wheels into a clean venv (POSIX) + id: smoke-venv-posix + if: runner.os != 'Windows' env: - RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + RUNTIME_WHEEL: ${{ steps.runtime-posix.outputs.wheel }} SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail venv="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))')" python -m venv "$venv" - if [ "$RUNNER_OS" = Windows ]; then - smoke_python="$(cygpath -u "$venv")/Scripts/python.exe" - else - smoke_python="$venv/bin/python" - fi + smoke_python="$venv/bin/python" "$smoke_python" -m pip install \ "dist-python/$SDK_WHEEL" \ "dist-python/$RUNTIME_WHEEL" echo "python=$smoke_python" >> "$GITHUB_OUTPUT" - - name: Run installed-wheel keyless black-box tests + - name: Install local SDK and runtime wheels into a clean venv (Windows) + id: smoke-venv-windows + if: runner.os == 'Windows' + shell: pwsh + env: + RUNTIME_WHEEL: ${{ steps.runtime-windows.outputs.wheel }} + SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl + run: | + $venv = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))').Trim() + python -m venv $venv + $smokePython = Join-Path $venv 'Scripts\python.exe' + & $smokePython -m pip install "dist-python/$env:SDK_WHEEL" "dist-python/$env:RUNTIME_WHEEL" + if ($LASTEXITCODE -ne 0) { throw "Wheel installation failed with exit code $LASTEXITCODE" } + "python=$smokePython" >> $env:GITHUB_OUTPUT + + - name: Run installed-wheel keyless black-box tests (POSIX) + if: runner.os != 'Windows' run: | set -euo pipefail blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-"))')" - if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "${{ steps.smoke-venv.outputs.python }}" \ + "${{ steps.smoke-venv-posix.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario all \ --installed-wheel - - name: Preflight installed-wheel real API test + - name: Run installed-wheel keyless black-box tests (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $blackboxRoot = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-"))').Trim() + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + Remove-Item Env:DSH_RUNTIME_MODE -ErrorAction SilentlyContinue + Push-Location $blackboxRoot + try { + & "${{ steps.smoke-venv-windows.outputs.python }}" "$env:GITHUB_WORKSPACE\scripts\smoke-python-runtime.py" --scenario all --installed-wheel + if ($LASTEXITCODE -ne 0) { throw "Installed-wheel black-box failed with exit code $LASTEXITCODE" } + } finally { + Pop-Location + } + + - name: Preflight installed-wheel real API test (POSIX) if: >- inputs.ci + && runner.os != 'Windows' && (github.event_name != 'pull_request' || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]')) @@ -328,9 +363,25 @@ jobs: exit 1 fi - - name: Run installed-wheel real API black-box test + - name: Preflight installed-wheel real API test (Windows) if: >- inputs.ci + && runner.os == 'Windows' + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + shell: pwsh + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + run: | + if ([string]::IsNullOrWhiteSpace($env:DEEPSEEK_API_KEY)) { + throw 'DEEPSEEK_API_KEY_EXTERNAL is empty; the installed-wheel real API test cannot self-skip.' + } + + - name: Run installed-wheel real API black-box test (POSIX) + if: >- + inputs.ci + && runner.os != 'Windows' && (github.event_name != 'pull_request' || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]')) @@ -340,19 +391,41 @@ jobs: run: | set -euo pipefail blackbox_root="$(python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-live-"))')" - if [ "$RUNNER_OS" = Windows ]; then blackbox_root="$(cygpath -u "$blackbox_root")"; fi cd "$blackbox_root" env -u PYTHONPATH -u DSH_RUNTIME_MODE \ - "${{ steps.smoke-venv.outputs.python }}" \ + "${{ steps.smoke-venv-posix.outputs.python }}" \ "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ --scenario sdk-live \ --installed-wheel + - name: Run installed-wheel real API black-box test (Windows) + if: >- + inputs.ci + && runner.os == 'Windows' + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + shell: pwsh + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + DEEPSEEK_BASE_URL: https://api.deepseek.com + run: | + $blackboxRoot = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-blackbox-live-"))').Trim() + Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue + Remove-Item Env:DSH_RUNTIME_MODE -ErrorAction SilentlyContinue + Push-Location $blackboxRoot + try { + & "${{ steps.smoke-venv-windows.outputs.python }}" "$env:GITHUB_WORKSPACE\scripts\smoke-python-runtime.py" --scenario sdk-live --installed-wheel + if ($LASTEXITCODE -ne 0) { throw "Installed-wheel live API smoke failed with exit code $LASTEXITCODE" } + } finally { + Pop-Location + } + - name: Check Linux GLIBC requirements if: runner.os == 'Linux' run: | set -euo pipefail - readelf --version-info "${{ steps.runtime.outputs.exe }}" | tee glibc-versions.txt + readelf --version-info "${{ steps.runtime-posix.outputs.exe }}" | tee glibc-versions.txt maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' glibc-versions.txt | sort -V | tail -1)" [ -n "$maximum" ] || { echo "::error::No GLIBC requirements found"; exit 1; } dpkg --compare-versions "$maximum" le 2.28 || { @@ -363,7 +436,7 @@ jobs: - name: Check macOS deployment target if: runner.os == 'macOS' env: - EXE: ${{ steps.runtime.outputs.exe }} + EXE: ${{ steps.runtime-posix.outputs.exe }} run: >- python3 scripts/check-macos-deployment-target.py "$EXE" "$EXE-spawn-helper" @@ -372,7 +445,7 @@ jobs: if: runner.os == 'Linux' env: RUNNER_ARCH: ${{ runner.arch }} - RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + RUNTIME_WHEEL: ${{ steps.runtime-posix.outputs.wheel }} SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail @@ -392,7 +465,7 @@ jobs: - uses: actions/upload-artifact@v7 with: - name: ${{ steps.runtime.outputs.wheel }} - path: dist-python/${{ steps.runtime.outputs.wheel }} + name: ${{ steps.runtime-posix.outputs.wheel || steps.runtime-windows.outputs.wheel }} + path: dist-python/${{ steps.runtime-posix.outputs.wheel || steps.runtime-windows.outputs.wheel }} if-no-files-found: error retention-days: 7 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0a663cadf7..008200560c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -112,6 +112,7 @@ runtime-windows-x64: - $env:DSH_WHEEL_VERSION = (& .ci-python\Scripts\python.exe -c 'import runpy; release = runpy.run_path("scripts/build-python-release.py"); print(release["pep440_version"](release["repository_version"]()))') - if ($env:CI_COMMIT_TAG -ne "python-v$env:DSH_VERSION") { throw "Tag $env:CI_COMMIT_TAG does not match package.json version $env:DSH_VERSION" } - .ci-python\Scripts\python.exe -m pip install uv==0.11.23 + - $env:Path = (Join-Path $PWD ".ci-python\Scripts") + [IO.Path]::PathSeparator + $env:Path script: - corepack enable - pnpm install --frozen-lockfile diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 10a8c18c63..cea6e81135 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md -python-sdk.md: 5fd8b35c08acdd0f0ff457547ca62b31e12994d5 -python-sdk.zh.md: 354d6829dc07056556d19ddfca68a95ad3a5b47f +python-sdk.md: 388b259f0adbba11b7d359fcf861980cf0a3bec7 +python-sdk.zh.md: 2cc23e5cd1d7d7df5ad4b27441c54e6c3239c917 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 5fd8b35c08..388b259f0a 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -14,6 +14,8 @@ This tutorial installs the published Python SDK, runs the shipped standalone min ## Install the SDK +### Linux and macOS + ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -22,19 +24,40 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` +### Windows PowerShell + +```powershell +git clone https://github.com/deepseek-ai/deepseek-harness.git +Set-Location deepseek-harness +py -3.10 -m venv .venv +.venv\Scripts\Activate.ps1 +python -m pip install deepseek-harness-sdk +``` + The installation includes a matching native runtime wheel and the `dsh` command. Normal SDK execution needs no system Node.js. Repository contributors who build the artifacts should use the [Python contributor workflow](../../../python/development.md). ## Run the checked-in example Export the credential and, when needed, a compatible proxy endpoint: +### Linux and macOS + ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 ``` +### Windows PowerShell + +```powershell +$env:DEEPSEEK_API_KEY = "sk-your-key-here" +# $env:DEEPSEEK_BASE_URL = "http://127.0.0.1:8000/v1" +``` + Run one task with explicit workspace and home paths: +### Linux and macOS + ```sh python examples/python-sdk-agent/minimal.py \ --workspace /absolute/path/to/disposable-workspace \ @@ -43,6 +66,16 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` +### Windows PowerShell + +```powershell +python examples/python-sdk-agent/minimal.py ` + --workspace C:\work\disposable-workspace ` + --dsh-home C:\work\example-dsh-home ` + --session-id example-001 ` + "Inspect the repository and fix the failing tests." +``` + The script prints the final assistant response. The selected home receives the generated `sdk-minimal` profile, installed plugins, and uncompressed JSONL session logs under `sessions/`. The example and SDK never silently read `~/.dsh`. ## Use the SDK in your program @@ -76,12 +109,22 @@ The SDK starts the bundled `dsh --profile sdk-minimal` process lazily and reuses Use `dsh plugin` for dependencies and bundle layers that should persist in this home: +### Linux and macOS + ```sh export DSH_HOME=/absolute/path/to/example-dsh-home dsh --profile sdk-minimal --dump-default-config >/dev/null dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` +### Windows PowerShell + +```powershell +$env:DSH_HOME = "C:\work\example-dsh-home" +dsh --profile sdk-minimal --dump-default-config | Out-Null +dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle +``` + The first command initializes the shipped standalone profile. The second forwards package management to `pnpm`, then records any installed package that exports a `dsh.bundle` layer. Install `pnpm` only for this management command; launching the installed SDK does not need it. Edit `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml` for persistent row changes, or pass patch files from Python for per-launch changes. Another `profile` is valid when it includes `@deepseek-ai/dsh-sdk-app` or another JSON-RPC server row. Missing server rows, unresolved plugins, and invalid patches fail during startup instead of falling back to another composition. diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index 354d6829dc..2cc23e5cd1 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -14,6 +14,8 @@ ## 安装 SDK +### Linux 与 macOS + ```sh git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness @@ -22,19 +24,40 @@ python -m venv .venv python -m pip install deepseek-harness-sdk ``` +### Windows PowerShell + +```powershell +git clone https://github.com/deepseek-ai/deepseek-harness.git +Set-Location deepseek-harness +py -3.10 -m venv .venv +.venv\Scripts\Activate.ps1 +python -m pip install deepseek-harness-sdk +``` + 安装内容包含匹配的原生运行时 wheel 与 `dsh` 命令。普通 SDK 运行不需要系统 Node.js。需要构建产物的仓库贡献者应使用 [Python 贡献者工作流](../../../python/development.zh.md)。 ## 运行检入示例 导出凭据;使用兼容代理时再设置 endpoint: +### Linux 与 macOS + ```sh export DEEPSEEK_API_KEY=sk-your-key-here # export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 ``` +### Windows PowerShell + +```powershell +$env:DEEPSEEK_API_KEY = "sk-your-key-here" +# $env:DEEPSEEK_BASE_URL = "http://127.0.0.1:8000/v1" +``` + 使用显式 workspace 与 home 路径运行一个任务: +### Linux 与 macOS + ```sh python examples/python-sdk-agent/minimal.py \ --workspace /absolute/path/to/disposable-workspace \ @@ -43,6 +66,16 @@ python examples/python-sdk-agent/minimal.py \ "Inspect the repository and fix the failing tests." ``` +### Windows PowerShell + +```powershell +python examples/python-sdk-agent/minimal.py ` + --workspace C:\work\disposable-workspace ` + --dsh-home C:\work\example-dsh-home ` + --session-id example-001 ` + "Inspect the repository and fix the failing tests." +``` + 脚本会打印最终 assistant 响应。所选 home 会保存生成的 `sdk-minimal` profile、已安装插件,以及 `sessions/` 下的未压缩 JSONL 会话日志。示例与 SDK 绝不会静默读取 `~/.dsh`。 ## 在程序中使用 SDK @@ -76,12 +109,22 @@ SDK 会延迟启动内置的 `dsh --profile sdk-minimal` 进程,并复用到 需要在该 home 中持久保存依赖与 bundle 层时,使用 `dsh plugin`: +### Linux 与 macOS + ```sh export DSH_HOME=/absolute/path/to/example-dsh-home dsh --profile sdk-minimal --dump-default-config >/dev/null dsh plugin --profile sdk-minimal add file:/absolute/path/to/my-plugin-bundle ``` +### Windows PowerShell + +```powershell +$env:DSH_HOME = "C:\work\example-dsh-home" +dsh --profile sdk-minimal --dump-default-config | Out-Null +dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle +``` + 第一个命令初始化随附的独立 profile。第二个命令把包管理转发给 `pnpm`,然后记录所有导出 `dsh.bundle` 层的已安装包。只有执行此管理命令时才需要安装 `pnpm`;启动已安装 SDK 不需要它。持久配置项变更应编辑 `$DSH_HOME/profiles/sdk-minimal/cordis.patch.yml`;单次启动变更则从 Python 传入 patch 文件。 另一个 `profile` 只有包含 `@deepseek-ai/dsh-sdk-app` 或另一个 JSON-RPC server 配置项时才有效。缺失 server 配置项、无法解析的插件和非法 patch 会在启动时失败,不会回退到其他组合。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 4834a029d1..2081aa5070 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -116,7 +116,12 @@ def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: def _current_platform_tag() -> str: plat = _PLATFORM_TAGS.get(sys.platform) arch = _ARCH_TAGS.get(platform.machine().lower()) - if plat is None or arch is None or (plat == "win" and arch != "x64"): + if ( + plat is None + or arch is None + or (plat == "win" and arch != "x64") + or (plat == "macos" and arch != "arm64") + ): raise FileNotFoundError( "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 829c3b73f8..37d2a01734 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -137,3 +137,16 @@ def test_stage_runtime_copies_platform_payload( assert (destination / "THIRD_PARTY_NOTICES.md").read_bytes() == ( ROOT / "THIRD_PARTY_NOTICES.md" ).read_bytes() + + +def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) -> None: + executable = tmp_path / "renamed.exe" + executable.write_bytes(b"runtime") + + with pytest.raises(ValueError, match="must be named deepseek-harness-sdk-runtime-win-x64.exe"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + "deepseek-harness-sdk-runtime-win-x64.exe", + ) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index beaf5cfd6b..0f06deb28e 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -79,6 +79,14 @@ def test_current_platform_supports_windows_x64_only(monkeypatch: pytest.MonkeyPa runtime._current_platform_tag() +def test_current_platform_rejects_macos_x64(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(runtime.sys, "platform", "darwin") + monkeypatch.setattr(runtime.platform, "machine", lambda: "x86_64") + + with pytest.raises(FileNotFoundError, match="macOS arm64"): + runtime._current_platform_tag() + + def test_runtime_requires_ripgrep_sidecar( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 307fe759dd..085974f921 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -208,6 +208,10 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: + if executable.name != executable_name: + raise ValueError( + f"runtime executable must be named {executable_name}, got {executable.name}" + ) copy_package(ROOT / "python" / "sdk-runtime", destination) stage_license_files(destination, include_notices=True) rewrite_version(destination / "pyproject.toml", version) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index dcaa5e19a2..7f60d75352 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -390,12 +390,19 @@ describe('Python release workflows', () => { const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') - const cleanVenv = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv') - const installedKeyless = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests') - const realApiPreflight = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test') - const installedRealApi = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test') - if (!isRecord(cleanVenv) || !isRecord(installedKeyless) || !isRecord(realApiPreflight) || !isRecord(installedRealApi)) { - throw new TypeError('Python wheel builder must define installed-wheel keyless and real API steps') + const cleanVenvPosix = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (POSIX)') + const cleanVenvWindows = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (Windows)') + const installedKeylessPosix = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests (POSIX)') + const installedKeylessWindows = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel keyless black-box tests (Windows)') + const realApiPreflightPosix = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test (POSIX)') + const realApiPreflightWindows = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test (Windows)') + const installedRealApiPosix = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (POSIX)') + const installedRealApiWindows = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (Windows)') + if (!isRecord(cleanVenvPosix) || !isRecord(cleanVenvWindows) + || !isRecord(installedKeylessPosix) || !isRecord(installedKeylessWindows) + || !isRecord(realApiPreflightPosix) || !isRecord(realApiPreflightWindows) + || !isRecord(installedRealApiPosix) || !isRecord(installedRealApiWindows)) { + throw new TypeError('Python wheel builder must define native POSIX and Windows installed-wheel steps') } expect(call.inputs).toHaveProperty('targets') expect(call.inputs).toMatchObject({ @@ -408,7 +415,7 @@ describe('Python release workflows', () => { expect(workflow.concurrency).toMatchObject({ group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}', }) - expect(build.defaults).toMatchObject({ run: { shell: 'bash' } }) + expect(build.defaults).toBeUndefined() expect(plan.if).toContain('inputs.ci') expect(plan.if).toContain('inputs.release') expect(JSON.stringify(plan.steps)).toContain('pep440_version') @@ -423,6 +430,7 @@ describe('Python release workflows', () => { expect(workflowJson).toContain('/work/dist-python/$RUNTIME_WHEEL') expect(workflowJson).not.toContain('--find-links dist-python') expect(workflowJson).not.toContain('--find-links /work/dist-python') + expect(workflowJson).not.toContain('cygpath') expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') @@ -434,27 +442,29 @@ describe('Python release workflows', () => { expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) expect(JSON.stringify(macosCheck)).toContain('scripts/check-macos-deployment-target.py') expect(JSON.stringify(macosCheck)).toContain('$EXE-spawn-helper') - expect(JSON.stringify(installedKeyless)).toContain('--scenario all') - expect(JSON.stringify(installedKeyless)).toContain('--installed-wheel') - expect(JSON.stringify(installedKeyless)).toContain('env -u PYTHONPATH') - expect(JSON.stringify(installedKeyless)).toContain('-u DSH_RUNTIME_MODE') - expect(JSON.stringify(cleanVenv)).toContain('Scripts/python.exe') - expect(realApiPreflight).toMatchObject({ + expect(JSON.stringify(installedKeylessPosix)).toContain('--scenario all') + expect(JSON.stringify(installedKeylessPosix)).toContain('env -u PYTHONPATH') + expect(JSON.stringify(installedKeylessWindows)).toContain('--scenario all --installed-wheel') + expect(installedKeylessWindows).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' }) + expect(cleanVenvWindows).toMatchObject({ if: "runner.os == 'Windows'", shell: 'pwsh' }) + expect(JSON.stringify(cleanVenvWindows)).toContain('Scripts\\\\python.exe') + expect(realApiPreflightPosix).toMatchObject({ env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' }, }) - expect(String(realApiPreflight.if)).toContain('inputs.ci') - expect(String(realApiPreflight.if)).toContain('head.repo.fork') - expect(String(realApiPreflight.if)).toContain('dependabot[bot]') - expect(installedRealApi).toMatchObject({ + expect(String(realApiPreflightPosix.if)).toContain('inputs.ci') + expect(String(realApiPreflightPosix.if)).toContain('head.repo.fork') + expect(String(realApiPreflightPosix.if)).toContain('dependabot[bot]') + expect(realApiPreflightWindows).toMatchObject({ shell: 'pwsh' }) + expect(installedRealApiPosix).toMatchObject({ env: { DEEPSEEK_API_KEY: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}', DEEPSEEK_BASE_URL: 'https://api.deepseek.com', }, }) - expect(installedRealApi.if).toBe(realApiPreflight.if) - expect(JSON.stringify(installedRealApi)).toContain('--scenario sdk-live') - expect(JSON.stringify(installedRealApi)).toContain('--installed-wheel') - expect(JSON.stringify(installedRealApi)).toContain('-u DSH_RUNTIME_MODE') + expect(JSON.stringify(installedRealApiPosix)).toContain('--scenario sdk-live') + expect(JSON.stringify(installedRealApiPosix)).toContain('-u DSH_RUNTIME_MODE') + expect(installedRealApiWindows).toMatchObject({ shell: 'pwsh' }) + expect(JSON.stringify(installedRealApiWindows)).toContain('--scenario sdk-live --installed-wheel') expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED') }) @@ -481,12 +491,15 @@ describe('Python release workflows', () => { const workflow = loadWorkflow('.gitlab-ci.yml') const windows = workflow['runtime-windows-x64'] const publish = workflow['publish-python'] - if (!isRecord(windows) || !Array.isArray(windows.script) || !isRecord(publish) || !Array.isArray(publish.needs)) { + if (!isRecord(windows) || !Array.isArray(windows.before_script) || !Array.isArray(windows.script) + || !isRecord(publish) || !Array.isArray(publish.needs)) { throw new TypeError('GitLab CI must define the Windows runtime and aggregate publication jobs') } expect(windows.tags).toEqual(['windows-x64']) expect(windows.variables).toMatchObject({ PKG_TARGET: 'node24-win-x64', PLATFORM: 'win-x64' }) + expect(JSON.stringify(windows.before_script)).toContain('.ci-python\\\\Scripts') + expect(JSON.stringify(windows.before_script)).toContain('[IO.Path]::PathSeparator') expect(JSON.stringify(windows.script)).toContain('win_amd64.whl') expect(JSON.stringify(windows.script)).toContain('--scenario all --installed-wheel') expect(publish.needs).toContainEqual({ job: 'runtime-windows-x64', artifacts: true }) From dff3e18afdafd06cb4b203480b8a68d655dab920 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:07:21 +0800 Subject: [PATCH 42/42] fix(python): budget cold profile initialization The Windows x64 installed-wheel job timed out while waiting for initialize even though the same head passed on rerun. Exact packaged-runtime VM evidence showed a 6.47-second first cold handshake and 2.69-2.94-second warm fresh-home handshakes, leaving too little variance below the public 10-second default.\n\nRaise the independent initialize default to 30 seconds in both Python SDK configuration layers. Ordinary turn and shutdown timeouts remain unchanged, callers retain an explicit override, and tests plus paired documentation pin the public behavior. --- .../2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml | 4 ++-- .../architecture/2026-08-23-python-sdk-windows-x64-runtime.md | 2 ++ .../2026-08-23-python-sdk-windows-x64-runtime.zh.md | 2 ++ python/sdk/README.i18n.yaml | 4 ++-- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 2 +- python/sdk/src/deepseek_harness/client.py | 2 +- python/sdk/tests/test_client.py | 2 ++ 9 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml index 0da9832af7..3db66f3a7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.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/architecture/2026-08-23-python-sdk-windows-x64-runtime.md -2026-08-23-python-sdk-windows-x64-runtime.md: b4ba54d9bd8e7a2eaa9277116a7868a1942d0531 -2026-08-23-python-sdk-windows-x64-runtime.zh.md: 6f05817b647a152cec626f09866f415d164064ec +2026-08-23-python-sdk-windows-x64-runtime.md: 59a46d99f9e7ed411aeffbb541bbe3bb0c752078 +2026-08-23-python-sdk-windows-x64-runtime.zh.md: 3ab972aabb8135c8bc6285d129ba7bc9335eb11f diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md index b4ba54d9bd..59a46d99f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md @@ -26,6 +26,8 @@ The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside the The Windows lane creates a clean Windows virtual environment, installs the exact SDK and `win_amd64` runtime wheels, changes to a directory outside the checkout, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and runs the same `--scenario all --installed-wheel` blackbox as every other target. Trusted pull requests also run the same two-turn `sdk-live` provider scenario. Fork and Dependabot heads receive no key. +The public Python client gives the initial profile handshake an independent 30-second default through `initialize_timeout_seconds`. The bound accommodates cold Windows x64 executable startup and profile materialization while still failing a stuck runtime; callers may configure it separately from ordinary request timeouts. + After a successful shutdown response, the Python client closes stdin and waits within the configured shutdown timeout for the `dsh` context to exit and flush durable session state before terminating it. A failed shutdown retains immediate bounded termination. `shutdown_timeout_seconds` bounds each of the shutdown request, EOF grace, and termination-confirmation phases, so a pathological close can approach three times that value before the final kill. This distinction preserves the final accepted turn on Windows, where `terminate()` force-kills the process rather than delivering a catchable signal. The minimal blackbox uses persistent `pwsh` plus `str_replace_editor` on Windows and owns `minimal/win-x64/model-visible.json`; Linux and macOS retain persistent Bash and the shared `minimal/model-visible.json`. The advanced process/subagent snapshot and restart/durable-log snapshot remain shared across all targets. The shipped [`sdk-minimal` bundle](../../../../packages/bundle/sdk-minimal/README.md) selects the same platform shell pair for the runnable Python tutorial. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md index 6f05817b64..3ab972aabb 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md @@ -26,6 +26,8 @@ Python 进程仍按 [Python profile 运行时决策](2026-08-23-python-sdk-dsh-p Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配的 SDK 与 `win_amd64` 运行时 wheel,切换到 checkout 外的目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,再运行与其他目标相同的 `--scenario all --installed-wheel` 黑盒测试。可信拉取请求还会运行相同的双轮 `sdk-live` 真实提供方场景。Fork 与 Dependabot head 不会获得密钥。 +公开 Python 客户端通过 `initialize_timeout_seconds` 为首次 profile 握手提供独立的 30 秒默认上限。该上限可容纳 Windows x64 可执行文件冷启动与 profile 物化,同时仍会使卡死的运行时失败;调用方可将其与普通请求超时分开配置。 + 成功收到 shutdown 响应后,Python 客户端会关闭 stdin,并在已配置的 shutdown 超时内等待 `dsh` 上下文退出及刷写持久 session 状态,然后才回退到终止进程。Shutdown 失败时仍立即执行有界终止。`shutdown_timeout_seconds` 会分别限制 shutdown 请求、EOF 宽限与终止确认阶段,因此异常关闭在最终 kill 前可能接近该值的三倍。该区别会保留 Windows 上最后一个已接受轮次;该平台的 `terminate()` 会强制结束进程,而不是发送可捕获信号。 极简黑盒测试在 Windows 上使用持久 `pwsh` 与 `str_replace_editor`,并由 `minimal/win-x64/model-visible.json` 固定预期;Linux 与 macOS 保留持久 Bash 和共享的 `minimal/model-visible.json`。高级进程/subagent 快照与重启/持久日志快照继续由所有目标共享。随附的 [`sdk-minimal` 组合包](../../../../packages/bundle/sdk-minimal/README.zh.md)为可运行 Python 教程选择同一组平台 shell。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 5295ed3c11..c8ee3ec85f 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: cf9bb3e3ccac4908e9212d8f7247545b5a6b5d8e -README.zh.md: 9acb8f26129144a77a834f94854cdd3f1a200086 +README.md: 1b03fe5553f25da3bc62f8a7eec2a274b0afb66a +README.zh.md: c0bfa8bdd9e2ecbaad0a019a274b94516e219ac6 diff --git a/python/sdk/README.md b/python/sdk/README.md index cf9bb3e3cc..1b03fe5553 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 10-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. +`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. ## Customize plugins diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 9acb8f2612..c0bfa8bdd9 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -26,7 +26,7 @@ with DeepSeekHarness( print(result.final_response) ``` -`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 10 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 +`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 ## 自定义插件 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 09286a1ad1..a9a10f993c 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -29,7 +29,7 @@ class DeepSeekHarnessConfig: patches: tuple[str, ...] = () dsh_home: str | None = None env: dict[str, str] = field(default_factory=dict) - initialize_timeout_seconds: float = 10.0 + initialize_timeout_seconds: float = 30.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index f6752a9906..804076636d 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -31,7 +31,7 @@ class HarnessConfig: dsh_home: str | None = None cwd: str | None = None env: dict[str, str] | None = None - initialize_timeout_seconds: float = 10.0 + initialize_timeout_seconds: float = 30.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 _launch_args: tuple[str, ...] | None = None diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index fba315320b..d5ed7dada8 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -873,6 +873,8 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: ) assert "initialize_timeout_seconds" in DeepSeekHarnessConfig.__dataclass_fields__ assert "initialize_timeout_seconds" in HarnessConfig.__dataclass_fields__ + assert DeepSeekHarnessConfig().initialize_timeout_seconds == 30.0 + assert HarnessConfig().initialize_timeout_seconds == 30.0 for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ assert removed not in HarnessConfig.__dataclass_fields__