From 9c3971618af2f46bf80f273a8eb2db68e0273274 Mon Sep 17 00:00:00 2001 From: LogosVeritas Date: Fri, 23 Oct 2026 10:46:42 +0800 Subject: [PATCH] feat(sdk): support native goal slash commands --- packages/sdk/client/README.i18n.yaml | 8 +- packages/sdk/client/README.md | 6 +- packages/sdk/client/README.zh.md | 6 +- packages/sdk/client/src/client.ts | 2 +- packages/sdk/client/tests/query.spec.ts | 4 +- packages/sdk/protocol/README.i18n.yaml | 4 +- packages/sdk/protocol/README.md | 2 +- packages/sdk/protocol/README.zh.md | 2 +- packages/sdk/protocol/src/types.ts | 2 +- packages/sdk/server/README.i18n.yaml | 16 +- packages/sdk/server/README.md | 20 +- packages/sdk/server/README.zh.md | 20 +- packages/sdk/server/package.json | 4 + packages/sdk/server/src/query-input.ts | 6 +- packages/sdk/server/src/server.ts | 6 +- .../sdk/server/tests/command-model-input.ts | 25 +++ packages/sdk/server/tests/commands.spec.ts | 6 +- packages/sdk/server/tests/goal-parity.spec.ts | 89 +++++++++ packages/sdk/server/tests/plan-parity.spec.ts | 20 +- packages/sdk/server/tests/query-input.spec.ts | 9 +- pnpm-lock.yaml | 12 ++ python/sdk/README.i18n.yaml | 8 +- python/sdk/README.md | 8 +- python/sdk/README.zh.md | 8 +- python/sdk/examples/README.i18n.yaml | 7 +- python/sdk/examples/README.md | 14 ++ python/sdk/examples/README.zh.md | 14 ++ python/sdk/examples/goal.py | 76 ++++++++ python/sdk/src/deepseek_harness/client.py | 8 +- python/sdk/tests/test_client.py | 9 + python/sdk/tests/test_commands.py | 2 +- python/sdk/tests/test_commands_runtime.py | 34 ++-- python/sdk/tests/test_goal_runtime.py | 177 ++++++++++++++++++ python/sdk/tests/test_queries.py | 2 +- snapshots/sdk/goal-rounds/capture.cordis.yml | 5 + .../sdk/goal-rounds/replay.override.json | 66 +++++++ snapshots/sdk/goal-rounds/session.v4.jsonl | 58 ++++++ snapshots/sdk/goal-rounds/snapshot.yml | 9 + snapshots/sdk/sdk.snapshot.ts | 32 +++- 39 files changed, 728 insertions(+), 78 deletions(-) create mode 100644 packages/sdk/server/tests/command-model-input.ts create mode 100644 packages/sdk/server/tests/goal-parity.spec.ts create mode 100644 python/sdk/examples/goal.py create mode 100644 python/sdk/tests/test_goal_runtime.py create mode 100644 snapshots/sdk/goal-rounds/capture.cordis.yml create mode 100644 snapshots/sdk/goal-rounds/replay.override.json create mode 100644 snapshots/sdk/goal-rounds/session.v4.jsonl create mode 100644 snapshots/sdk/goal-rounds/snapshot.yml diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index 091d2e1337..4751a5b061 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -21,11 +21,11 @@ en: 01c58dddd7e62de2 zh: 6dbd46e244f3f135 /deepseek-ai-dsh-sdk-client/use-this-package/sending-input-and-receiving-responses: - en: a93907040e4e3f4a - zh: 3ffb0967a4e1465e + en: b34bfbb810f55724 + zh: ec4756e11d7fb037 /deepseek-ai-dsh-sdk-client/use-this-package/lower-level-control-with-harnessclient: - en: 39d713b57c92f177 - zh: d07a74bd49b7cb1d + en: 73217d8d1817f532 + zh: cf92102afb34acfc /deepseek-ai-dsh-sdk-client/use-this-package/native-user-questions: en: f5b9d6a7363bd9e5 zh: f53b3786d22a729d diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 91e4cf03ff..b3e8e78f35 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -55,7 +55,7 @@ The subprocess starts lazily on first use and stays owned by the instance across ### Sending input and receiving responses -`await session.query(text, { delivery: 'queue' })` starts the runtime if needed, sends text, and returns without awaiting runtime routing or execution. Delivery defaults to `queue`; `steer` targets the next available step for ordinary input. The full SDK intercepts bare `/compact`, `/permission ` with a non-blank preset, and `/plan [off|message]`, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/model`, and `/compact extra` remain ordinary prompts. `run()` never dispatches these commands. Query is text-only; use `run()` for content-block prompts. +`await session.query(text, { delivery: 'queue' })` starts the runtime if needed, sends text, and returns without awaiting runtime routing or execution. Delivery defaults to `queue`; `steer` targets the next available step for ordinary input. The full SDK intercepts bare `/compact`, `/permission ` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/model`, and `/compact extra` remain ordinary prompts. `run()` never dispatches these commands. Query is text-only; use `run()` for content-block prompts. ```ts import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' @@ -79,7 +79,9 @@ Launch options provide initial values, not a shared live configuration object. A ### Lower-level control with HarnessClient -`client.query(sessionId, text, delivery, signal?)` waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` is the strict entry for `/compact`, `/permission `, and `/plan [off|message]`; it rejects bare `/permission`. A non-blank unknown preset reaches the native handler and returns `result.kind === "error"`. Subscribe before these calls when notifications are needed. An optional fourth `request()` argument supplies an `AbortSignal` that abandons only the local wait; runtime work can continue after timeout or local abandonment. +`client.query(sessionId, text, delivery, signal?)` waits for a validated prompt receipt or command result. `client.executeCommand(sessionId, line)` is the strict entry for `/compact`, `/permission `, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`; it rejects bare `/permission`. A non-blank unknown preset reaches the native handler and returns `result.kind === "error"`. Subscribe before these calls when notifications are needed. An optional fourth `request()` argument supplies an `AbortSignal` that abandons only the local wait; runtime work can continue after timeout or local abandonment. + +`/goal` uses the native goal handler and can start automatic rounds after its `CommandExecution`. Subscribe before sending; observe a terminal `goal/change` operation before using `session/wait` for the closing reply. Idle between rounds is not goal completion. See the [server semantics](../server/README.md) for pause, clear, restoration, and permission behavior. `HarnessClient` is the protocol client under the run API: explicit `start()`, `initialize()`, `prompt()`, `request()`, and `close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it and never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index 399930c3ac..68a05b063b 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -55,7 +55,7 @@ console.log(result.finalResponse) ### 发送输入与接收响应 -`await session.query(text, { delivery: 'queue' })` 在需要时启动运行时、发送文本,然后返回,不等待运行时的路由或执行结果。delivery 默认为 `queue`;对普通输入,`steer` 指向下一个可用步骤。完整 SDK 接管裸 `/compact`、带非空 preset 的 `/permission ` 和 `/plan [off|message]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 和 `/compact extra` 仍是普通提示词。`run()` 不分派这些命令。query 只接受文本,内容块提示词请使用 `run()`。 +`await session.query(text, { delivery: 'queue' })` 在需要时启动运行时、发送文本,然后返回,不等待运行时的路由或执行结果。delivery 默认为 `queue`;对普通输入,`steer` 指向下一个可用步骤。完整 SDK 接管裸 `/compact`、带非空 preset 的 `/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 和 `/compact extra` 仍是普通提示词。`run()` 不分派这些命令。query 只接受文本,内容块提示词请使用 `run()`。 ```ts import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' @@ -79,7 +79,9 @@ for await (const message of session.receiveResponse()) { ### 用 HarnessClient 做低层控制 -`client.query(sessionId, text, delivery, signal?)` 等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 是 `/compact`、`/permission ` 和 `/plan [off|message]` 的严格入口,会拒绝裸 `/permission`。非空的未知 preset 会进入原生 handler,返回 `result.kind === "error"`。需要通知时,应在调用前订阅。`request()` 的可选第四个参数接受 `AbortSignal`,仅放弃本地等待;超时或本地放弃后,运行时工作仍可继续。 +`client.query(sessionId, text, delivery, signal?)` 等待经过校验的提示词回执或命令结果。`client.executeCommand(sessionId, line)` 是 `/compact`、`/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]` 的严格入口,会拒绝裸 `/permission`。非空的未知 preset 会进入原生 handler,返回 `result.kind === "error"`。需要通知时,应在调用前订阅。`request()` 的可选第四个参数接受 `AbortSignal`,仅放弃本地等待;超时或本地放弃后,运行时工作仍可继续。 + +`/goal` 使用原生目标 handler,可在 `CommandExecution` 返回后启动自动轮次。发送前应订阅;先观察终态 `goal/change` 操作,再用 `session/wait` 等待收尾回复。轮次之间的 idle 不代表目标完成。暂停、清除、恢复和权限行为见[服务端语义](../server/README.zh.md)。 `HarnessClient` 是运行 API 之下的协议客户端:显式 `start()`、`initialize()`、`prompt()`、`request()` 与 `close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 id,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。 diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index 97c2633e21..085120c26c 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -364,7 +364,7 @@ export class HarnessClient { /** * Execute a strict command without waiting for an inbox receipt or Agent idle. * @param sessionId - target Session; an unknown id creates it. - * @param line - `/compact`, `/permission `, or `/plan [off|message]`; handlers validate arguments. + * @param line - supported compact, permission, plan, or goal input; handlers validate arguments. * @returns the native command result, including a business error when the handler refuses it. * @throws on protocol rejection, malformed response, timeout or transport loss; never retries. */ diff --git a/packages/sdk/client/tests/query.spec.ts b/packages/sdk/client/tests/query.spec.ts index 2780226268..38e0c198cf 100644 --- a/packages/sdk/client/tests/query.spec.ts +++ b/packages/sdk/client/tests/query.spec.ts @@ -96,8 +96,8 @@ it('retains a rejection before reception without creating an unhandled rejected it('accepts ordinary prompt routing for other slash input and preserves its text', async () => { const session = harnessWith().session('main') - await session.query('/goal') - expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:/goal') + await session.query('/model') + expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply:/model') await session.query(' /compact extra ') expect(finalRun(await collect(session.receiveResponse())).finalResponse).toBe('reply: /compact extra ') }) diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 3dc95504cc..5035bd7ce4 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -21,8 +21,8 @@ en: be53c71505c0d1b4 zh: 6210dee577715539 /deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods: - en: 1e19f82ef5bd6938 - zh: 151457b56fce61af + en: 08dfe39d0df5a035 + zh: 079f9d60f8510642 /deepseek-ai-dsh-sdk-protocol/use-this-package/native-user-questions: en: 65ea66231b17b6cf zh: 8e1c57d0c27f842f diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 90d692dfae..6bca4f737d 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -62,7 +62,7 @@ Both wire ends share one method set: client-to-server requests, opt-in server-to `session/query` accepts exactly `{ sessionId, text, delivery }`, where text is non-blank and delivery is `queue` or `steer`. The runtime replies with `{ kind: "prompt", messageId }` for an ordinary input, or `{ kind: "command", execution }` after a supported command settles. A prompt receipt is not a model result; command completion does not require an inbox receipt or idle notification. Recognition and the supported command set belong to the [SDK server](../server/README.md). -`session/command` accepts exactly `{ sessionId, line }` after initialization and delegates `/compact`, `/permission `, or `/plan [off|message]` to the native registry. A non-blank preset is required; bare `/permission` is rejected. It returns `CommandExecution` after the handler settles, including an unknown preset or other business failure as `result.kind: "error"`. Unavailable or unsupported commands and malformed requests reject with a JSON-RPC error. There is no prompt fallback or attachment input. Command lifecycle notifications precede the response; no inbox receipt or ordinary turn is required. `session/prompt` remains unchanged. +`session/command` accepts exactly `{ sessionId, line }` after initialization and delegates `/compact`, `/permission `, `/plan [off|message]`, or `/goal [objective|clear|edit objective|pause|resume]` to the native registry. A non-blank preset is required; bare `/permission` is rejected. It returns `CommandExecution` after the handler settles, including an unknown preset or other business failure as `result.kind: "error"`. Unavailable or unsupported commands and malformed requests reject with a JSON-RPC error. There is no prompt fallback or attachment input. Command lifecycle notifications precede the response; no inbox receipt or ordinary turn is required. `session/prompt` remains unchanged. `session/resume` accepts a non-empty persisted `{ sessionId }` and returns `{}` after restoring that exact session. It requires initialization and the original persistence store, sends no new input, and rejects missing, unreadable, incompatible, or occupied sessions without creating a replacement. Historical events remain in the stored log; notifications report newly committed events. diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index 7e81072301..96a877cd51 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -62,7 +62,7 @@ kind: "package-library" `session/query` 接受且仅接受 `{ sessionId, text, delivery }`,text 不能全为空白,delivery 为 `queue` 或 `steer`。普通输入返回 `{ kind: "prompt", messageId }`,受支持命令结束后返回 `{ kind: "command", execution }`。Prompt 回执不是模型结果,命令完成也不要求 inbox 回执或 idle 通知。识别规则及受支持命令由 [SDK 服务端](../server/README.zh.md)定义。 -`session/command` 在初始化后接受且仅接受 `{ sessionId, line }`,将 `/compact`、`/permission ` 或 `/plan [off|message]` 委托给原生注册器。preset 必须非空,裸 `/permission` 会被拒绝。Handler 结束后返回 `CommandExecution`,未知 preset 或其他业务失败以 `result.kind: "error"` 返回。命令不可用、不受支持或请求格式错误时返回 JSON-RPC 错误。不回退为普通 prompt,也不接受附件。命令生命周期通知在响应前到达,不要求 inbox 回执或普通 turn。`session/prompt` 保持不变。 +`session/command` 在初始化后接受且仅接受 `{ sessionId, line }`,将 `/compact`、`/permission `、`/plan [off|message]` 或 `/goal [objective|clear|edit objective|pause|resume]` 委托给原生注册器。preset 必须非空,裸 `/permission` 会被拒绝。Handler 结束后返回 `CommandExecution`,未知 preset 或其他业务失败以 `result.kind: "error"` 返回。命令不可用、不受支持或请求格式错误时返回 JSON-RPC 错误。不回退为普通 prompt,也不接受附件。命令生命周期通知在响应前到达,不要求 inbox 回执或普通 turn。`session/prompt` 保持不变。 `session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{}`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index 53fa7c3721..1a1816f8d8 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -93,7 +93,7 @@ export interface SessionPromptResult { messageId: string } -/** Strict invocation of `/compact`, `/permission `, or `/plan [off|message]`. */ +/** Strict invocation of the supported compact, permission, plan, or goal commands. */ export interface SessionCommandParams { /** SDK-owned Session identity; an unknown id creates its Agent. */ sessionId: SessionId diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index a5501451d3..7a5fe8781a 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package: - en: 528fa29a24b6216f - zh: 2af7b105c8205c87 + en: 195506cccf805d45 + zh: f8c03003567028f4 /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 @@ -104,6 +104,18 @@ /deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/plan-commands/kv-cache-effect: en: 1403de19597c9bfd zh: a3d41798c45577cb +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/goal-commands: + en: 00f1a91f6d15e4a0 + zh: cad13107ea16b3de +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/goal-commands/what-the-model-sees: + en: 3b29854591a9432f + zh: 08dc50393d547828 +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/goal-commands/token-effect: + en: 7a4c40ba3c812bca + zh: 8ba8e1c5e1a1c58d +/deepseek-ai-dsh-sdk-jsonrpc-server/model-experience/goal-commands/kv-cache-effect: + en: f4edb02fb1124718 + zh: 0f70aae68553eb5a /deepseek-ai-dsh-sdk-jsonrpc-server/known-limitations-and-deferred-work: en: a6d0b12d02961572 zh: 92822e033db55980 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 852c6d9332..9f166b1a23 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -33,14 +33,16 @@ Set `initialize.approvalRequests` to `true` to send internal approvals as server The plugin publishes the `sdkSessionActivation` readiness marker once its reminder activation handler is installed; an SDK composition injects this marker into Schedule instead of the Web Session controller. Scheduled delivery uses this same SDK Session owner. Due startup reminders wait for successful SDK initialization, then resume with its provider, model, reasoning effort, and output limit. The client can resume a reminder-restored Session once; concurrent reminder and client restoration share one handle. Shutdown releases reminders waiting for initialization and drains accepted restorations. -`session/query` is the unified text entry. Ordinary text retains its original content and uses the selected queue/steer operation. The full SDK recognizes bare `/compact`, `/permission ` with a non-blank preset, and `/plan [off|message]`, including Desktop aliases bound to their current definitions. Bare `/permission` or `/权限`, `/compact extra`, and all other input including `/goal` and `/model` remain ordinary prompts. A missing command also preserves the prompt path; a selected handler's failure never falls back. An unknown preset is a native command error. Inputs are text-only; `session/prompt` retains its content-block API. +`session/query` is the unified text entry. Ordinary text retains its original content and uses the selected queue/steer operation. The full SDK recognizes bare `/compact`, `/permission ` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`, including Desktop aliases bound to their current definitions. Bare `/permission` or `/权限`, `/compact extra`, and all other input including `/model` remain ordinary prompts. A missing command also preserves the prompt path; a selected handler's failure never falls back. An unknown preset is a native command error. Inputs are text-only; `session/prompt` retains its content-block API. -`session/command` accepts `{ sessionId, line }` for `/compact`, `/permission `, or `/plan [off|message]` and rejects bare `/permission`. The full sdk profile composes these native handlers; profiles without the requested command reject it. The server resolves its own live Agent and calls `commands.execute()` without queueing a prompt or waiting for idle. Compaction owns its auxiliary model request, history replacement and checkpoint, and refuses a busy Agent. Permission changes retain their native state and notification behavior. Shutdown cancels pending commands and drains SDK command waiters before releasing Agents. Timeout is not rollback; clients must not retry automatically. See the [protocol](../protocol/README.md) for results and errors. +`session/command` accepts `{ sessionId, line }` for `/compact`, `/permission `, `/plan [off|message]`, or `/goal [objective|clear|edit objective|pause|resume]` and rejects bare `/permission`. The full sdk profile composes these native handlers; profiles without the requested command reject it. The server resolves its own live Agent and calls `commands.execute()` without queueing a prompt or waiting for idle. Compaction owns its auxiliary model request, history replacement and checkpoint, and refuses a busy Agent. Permission changes retain their native state and notification behavior. Shutdown cancels pending commands and drains SDK command waiters before releasing Agents. Timeout is not rollback; clients must not retry automatically. See the [protocol](../protocol/README.md) for results and errors. Permission commands change only the receiving Session. Later queries and restored Sessions retain its persisted permission facts; other Sessions keep their own state and new Sessions use the configured defaults. Neither the caller's launch configuration nor saved user defaults are rewritten. Model selection is not an SDK slash command; its resume override remains the separate behavior described above. `/plan` and `/plan off` change only native plan state. A plan body is submitted by its handler through steer; command completion does not wait for that model activity or the review. `session/wait` observes subsequent Session work. User-question answers preserve approval and feedback; `{ cancelled: true }` becomes native `ASK_CANCELLED`, allowing plan review to end the turn without leaving plan mode. +`/goal` delegates its full grammar to the [native goal command](../../goal/command-goal/README.md). Creation and resume arm automatic goal rounds; pause aborts a running turn, while clear stops continuation without aborting the current request. Command completion is separate from goal completion. `session/wait` can return between goal rounds: observe a terminal `goal/change` operation first, then wait for any closing model reply. Restoration retains goal state but leaves activation disarmed until `/goal resume`. Goal commands do not change permissions or SDK initialization settings. + `session/working-directory/get` accepts `{ sessionId }`; `session/working-directory/set` accepts `{ sessionId, path }`. Both return `{ cwd }` and create an unknown Session without starting a model turn. Reads recover missing directories through the working-directory service; changes preserve origin metadata and permissions, and reach the model through logged user context. ### Wiring @@ -180,6 +182,20 @@ Plan guidance, its exit-tool declaration, submitted plan Markdown, and review re Native system and tool updates determine prefix reuse; the SDK adds no extra plan prompt or invalidation. +### Goal commands + +#### What the model sees + +The native [goal driver](../../goal/goal-round-driver/README.md#model-experience) admits logged `` user messages. Goal tools retain their state and completion policy, including the `` or `` closing context. Command text and results stay outside model history. + +#### Token effect + +Each admitted round and closing step has its native model cost; SDK routing adds no model input. + +#### KV Cache effect + +Goal-round messages and tool results append to retained history under the native driver. + ## Known Limitations and Deferred Work diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 831615c538..4bda05e91c 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -33,14 +33,16 @@ kind: "package-reference" 插件在注册提醒激活处理方后发布 `sdkSessionActivation` 就绪标记;SDK 组合让 Schedule 依赖此标记,而非 Web Session controller。定时提醒也通过同一个 SDK Session 所有者投递。启动时已到期的提醒等待 SDK 成功初始化,再使用初始化指定的提供方、模型、推理强度和输出上限恢复会话。客户端可以首次接管提醒恢复的 Session;并发的提醒与客户端恢复共用一个 handle。关闭时释放等待初始化的提醒,并等待已经接纳的恢复完成。 -`session/query` 是统一文本入口。普通文本保留原文,使用选定的 queue/steer 操作。完整 SDK 识别裸 `/compact`、带非空 preset 的 `/permission ` 和 `/plan [off|message]`,包括绑定到当前定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/compact extra` 以及 `/goal`、`/model` 等其余输入仍是普通提示词。命令缺失时也保留提示词路径;选中的 handler 失败后绝不回退。未知 preset 是原生命令错误。此入口仅接受文本,`session/prompt` 保留内容块 API。 +`session/query` 是统一文本入口。普通文本保留原文,使用选定的 queue/steer 操作。完整 SDK 识别裸 `/compact`、带非空 preset 的 `/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,包括绑定到当前定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/compact extra` 以及 `/model` 等其余输入仍是普通提示词。命令缺失时也保留提示词路径;选中的 handler 失败后绝不回退。未知 preset 是原生命令错误。此入口仅接受文本,`session/prompt` 保留内容块 API。 -`session/command` 接受用于 `/compact`、`/permission ` 或 `/plan [off|message]` 的 `{ sessionId, line }`,并拒绝裸 `/permission`。完整 sdk profile 组成这些原生 handler;缺少所请求命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。压缩负责其辅助模型请求、历史替换和 checkpoint,并拒绝忙碌 Agent。权限变更保留原生状态和通知行为。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不等于回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 +`session/command` 接受用于 `/compact`、`/permission `、`/plan [off|message]` 或 `/goal [objective|clear|edit objective|pause|resume]` 的 `{ sessionId, line }`,并拒绝裸 `/permission`。完整 sdk profile 组成这些原生 handler;缺少所请求命令的 profile 会拒绝调用。服务端解析自己拥有的存活 Agent,并调用 `commands.execute()`,不排入普通 prompt,也不等待 idle。压缩负责其辅助模型请求、历史替换和 checkpoint,并拒绝忙碌 Agent。权限变更保留原生状态和通知行为。Shutdown 取消待决命令,在释放 Agent 前排空 SDK 命令等待者。超时不等于回滚,客户端不能自动重试。结果和错误见[协议](../protocol/README.zh.md)。 权限命令只修改接收命令的 Session。后续 query 和恢复的 Session 保留其持久化权限事实;其他 Session 保留各自状态,新 Session 使用配置的默认值。调用方的启动配置和已保存的用户默认值均不会被重写。模型选择不是 SDK slash 命令;恢复时的模型覆盖仍按上文描述的独立行为执行。 `/plan` 和 `/plan off` 只改变原生计划状态。带正文时由 handler 通过 steer 提交;命令完成不等待后续模型活动或审查,`session/wait` 可观察后续 Session 工作。问答响应保留批准和反馈语义;`{ cancelled: true }` 转为原生 `ASK_CANCELLED`,使关闭计划审查结束轮次而不退出计划模式。 +`/goal` 将完整语法委托给[原生目标命令](../../goal/command-goal/README.zh.md)。创建和恢复会启用自动目标轮次;暂停中止运行中的轮次,清除则停止续跑而不中止当前请求。命令完成与目标完成相互独立。`session/wait` 可能在目标轮次之间返回:应先观察终态 `goal/change` 操作,再等待可能的模型收尾回复。恢复会话保留目标状态,但 activation 保持 disarmed,直到执行 `/goal resume`。目标命令不改变权限或 SDK 初始化设置。 + `session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 ### 组装 @@ -180,6 +182,20 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 原生系统与工具更新决定前缀复用;SDK 不添加额外计划提示词或失效操作。 +### 目标命令 + +#### 模型看到什么 + +原生[目标驱动器](../../goal/goal-round-driver/README.zh.md#model-experience) 接纳已记录的 `` 用户消息。目标工具保留其状态与完成策略,包括 `` 或 `` 收尾上下文。命令文本与结果不进入模型历史。 + +#### Token 影响 + +每个接纳的轮次和收尾步骤具有各自的原生模型成本;SDK 路由不增加模型输入。 + +#### KV Cache 影响 + +目标轮次消息和工具结果按原生驱动器的行为追加到保留历史。 + ## 已知限制与延期工作 diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 075060d73d..4e331d0895 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -56,8 +56,11 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-command-compact": "workspace:*", + "@deepseek-ai/dsh-command-goal": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-compaction-basic": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-goal-round-driver": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", @@ -72,6 +75,7 @@ "@deepseek-ai/dsh-shell": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", diff --git a/packages/sdk/server/src/query-input.ts b/packages/sdk/server/src/query-input.ts index 9e0602321b..8f84b686cd 100644 --- a/packages/sdk/server/src/query-input.ts +++ b/packages/sdk/server/src/query-input.ts @@ -3,7 +3,7 @@ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' /** - * Recognize compact, explicit permission changes, and plan input; other text remains a prompt. + * Recognize compact, explicit permission changes, plan, and goal input; other text remains a prompt. * @param text - original input; the caller preserves it on the ordinary-prompt path. * @param commands - exact receiving Agent's current command descriptors. * @returns the supported command line using its effective name, otherwise undefined. @@ -15,11 +15,13 @@ export function queryCommand(text: string, commands: readonly CommandDescriptor[ const name = separator === -1 ? trimmed.slice(1) : trimmed.slice(1, separator) const alias = name === '压缩' ? '@deepseek-ai/dsh-command-compact' : name === '权限' ? '@deepseek-ai/dsh-permission-presets' - : name === '计划' ? '@deepseek-ai/dsh-plan-mode' : undefined + : name === '计划' ? '@deepseek-ai/dsh-plan-mode' + : name === '目标' ? '@deepseek-ai/dsh-command-goal' : undefined const descriptor = commands.find(command => command.name === name) ?? (alias === undefined ? undefined : commands.find(command => command.definitionId === alias)) if (descriptor?.name === 'compact' && separator === -1) return '/compact' if (descriptor?.name === 'permission' && separator !== -1) return `/permission${trimmed.slice(separator)}` if (descriptor?.name === 'plan') return `/plan${separator === -1 ? '' : trimmed.slice(separator)}` + if (descriptor?.name === 'goal') return `/goal${separator === -1 ? '' : trimmed.slice(separator)}` return undefined } diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index dec969ed30..79a9746142 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -312,7 +312,7 @@ export class HarnessSdkJsonRpcServer { } /** - * Execute supported compact, permission, or plan input through the native command registry. + * Execute supported compact, permission, plan, or goal input through the native command registry. * @param params - target Session and complete command line. * @returns the native handler result; command errors remain result values. * @throws for unsupported input, unavailable commands, or runtime/transport failure. @@ -321,9 +321,9 @@ export class HarnessSdkJsonRpcServer { if (!this.initialized) throw new Error('SDK server is not initialized') if (this.shuttingDown) throw new Error('SDK server is shutting down') const parsed = parseCommand(params.line) - if (parsed === undefined || (parsed.name !== 'compact' && parsed.name !== 'plan' + if (parsed === undefined || (parsed.name !== 'compact' && parsed.name !== 'plan' && parsed.name !== 'goal' && (parsed.name !== 'permission' || parsed.rawInput.trim() === ''))) { - throw new TypeError('The SDK command endpoint supports only /compact, /permission , or /plan [off|message]') + throw new TypeError('The SDK command endpoint supports only /compact, /permission , /plan [off|message], or /goal [objective|clear|edit objective|pause|resume]') } const commands = this.ctx.get('commands') if (commands === undefined) { diff --git a/packages/sdk/server/tests/command-model-input.ts b/packages/sdk/server/tests/command-model-input.ts new file mode 100644 index 0000000000..23180c5434 --- /dev/null +++ b/packages/sdk/server/tests/command-model-input.ts @@ -0,0 +1,25 @@ +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' +import { expect } from 'vitest' + +/** + * Retain model inputs while binding tool-history references to message positions. + * @param options - captured request from the native or SDK command path. + * @returns comparable model inputs with all message content preserved. + */ +export function modelInput(options: GenerateOptions) { + return structuredClone({ + provider: options.provider, model: options.model, system: options.system, + messages: options.messages.map(message => ({ role: message.role, content: message.content })), + tools: options.tools, + toolHistory: options.toolHistory === undefined ? undefined : { + tools: options.toolHistory.tools, + updates: options.toolHistory.updates.map(({ messageId, additions }) => { + const messageIndex = options.messages.findIndex(message => message.id === messageId) + expect(messageIndex).toBeGreaterThanOrEqual(0) + return { messageIndex, additions } + }), + }, + maxTokens: options.maxTokens, + reasoningEffort: options.reasoningEffort, temperature: options.temperature, stop: options.stop, + }) +} diff --git a/packages/sdk/server/tests/commands.spec.ts b/packages/sdk/server/tests/commands.spec.ts index 9318950d4f..4cd94ca1ab 100644 --- a/packages/sdk/server/tests/commands.spec.ts +++ b/packages/sdk/server/tests/commands.spec.ts @@ -84,7 +84,7 @@ describe('SDK command dispatch', () => { expect(h.ctx.agents.list()).toEqual([]) }) - it.each(['text', '/goal', '/unknown', '/permission', '/permission \t\n'])('rejects unsupported input %s without invoking another command', async (line) => { + it.each(['text', '/model', '/unknown', '/permission', '/permission \t\n'])('rejects unsupported input %s without invoking another command', async (line) => { const h = await setup() await h.initialize() const handler = vi.fn(() => ({ kind: 'success' as const })) @@ -268,13 +268,13 @@ describe('SDK query dispatch', () => { expect(queued).not.toHaveBeenCalled() }) - it.each(['/goal inspect', '/model', '/file', '/export', '/permission', ' \n/权限\t '])('preserves existing prompt behavior for unimplemented input %s', async (text) => { + it.each(['/feedback inspect', '/model', '/file', '/export', '/permission', ' \n/权限\t '])('preserves existing prompt behavior for unimplemented input %s', async (text) => { const h = await setup() const model = new TextModel() h.ctx.effect(() => h.ctx.llm.registerAdapter(['query-model'], model)) await h.server.initialize({ cwd: process.cwd(), provider: 'query-model', model: 'model' }) const handler = vi.fn(() => ({ kind: 'success' as const })) - h.ctx.effect(() => h.ctx.commands.register({ name: 'goal', description: 'Goal', input: { hint: 'Task' }, handler })) + h.ctx.effect(() => h.ctx.commands.register({ name: 'feedback', description: 'Feedback', input: { hint: 'Task' }, handler })) h.ctx.effect(() => h.ctx.commands.register({ name: 'permission', description: 'Permission', input: { hint: 'Preset' }, handler })) await expect(h.server.query({ sessionId: SessionId('q'), text, delivery: 'queue' })).resolves.toMatchObject({ kind: 'prompt' }) await h.server.wait({ sessionId: 'q' }) diff --git a/packages/sdk/server/tests/goal-parity.spec.ts b/packages/sdk/server/tests/goal-parity.spec.ts new file mode 100644 index 0000000000..c9f3f5d9e3 --- /dev/null +++ b/packages/sdk/server/tests/goal-parity.spec.ts @@ -0,0 +1,89 @@ +/** Compare autonomous goal rounds with the native Desktop command entry. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import * as CommandGoal from '@deepseek-ai/dsh-command-goal' +import CommandRuntime from '@deepseek-ai/dsh-commands' +import GoalService from '@deepseek-ai/dsh-goal' +import * as GoalDriver from '@deepseek-ai/dsh-goal-round-driver' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as GoalTools from '@deepseek-ai/dsh-tool-goal' +import { expect, it } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { HarnessSdkJsonRpcServer } from '../src/server.ts' +import { modelInput } from './command-model-input.ts' + +async function scenario(path: 'sdk' | 'native', cwd: string) { + const ctx = new Context() + let server: HarnessSdkJsonRpcServer | undefined + try { + await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(CommandRuntime) + await ctx.plugin(GoalService, { defaultMaxGoalRounds: 2 }) + await ctx.plugin(CommandGoal) + await ctx.plugin(GoalDriver) + await ctx.plugin(GoalTools, {}) + const model = new MockAdapter([textResponse('READY'), textResponse('FIRST'), textResponse('SECOND')]) + ctx.effect(() => ctx.llm.registerAdapter(['goal-parity'], model)) + server = new HarnessSdkJsonRpcServer(ctx, { + notify: () => {}, request: async () => { throw new Error('Goal commands must not request client input') }, + }) + await server.initialize({ cwd, provider: 'goal-parity', model: 'model', maxTokens: 123 }) + const sessionId = SessionId('goal-parity') + await server.prompt({ sessionId, contentBlocks: [{ type: 'text', text: 'Begin the same task.' }] }) + await server.wait({ sessionId }) + const agent = ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('The initial prompt did not create its Agent') + const blocked = new Promise((resolve) => { + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'goal/change' && event.data.operation === 'block') resolve() + }) + }) + const line = '/goal Verify the change.' + const result = path === 'sdk' + ? await server.query({ sessionId, text: line, delivery: 'queue' }) + : await ctx.commands.execute(agent, line, [], new AbortController().signal) + if (result === undefined) throw new Error('The goal command was not found') + const command = 'kind' in result && result.kind === 'command' ? result.execution : result + expect(command).toMatchObject({ result: { kind: 'success' } }) + await blocked + await server.wait({ sessionId }) + const events = agent.session.snapshotEvents() + const goal = ctx.goals.get(agent) + expect(goal).toMatchObject({ phase: 'blocked', roundsStarted: 2, blockedReason: { code: 'round-limit' } }) + return { + requests: model.requests.map(modelInput), + changes: events.flatMap(event => event.type === 'goal/change' ? [event.data.operation] : []), + rounds: events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal' + ? [{ round: event.data.source.round, revision: event.data.source.revision, + currentGoal: event.data.source.goalId === goal?.id }] : []), + turns: events.flatMap(event => event.type === 'turn/start' ? [event.data.turn] : []), + steps: events.flatMap(event => event.type === 'step/start' ? [event.data] : []), + permissionEvents: events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy'), + } + } finally { + try { await server?.shutdown() } finally { await ctx.fiber.dispose() } + } +} + +it('preserves native model inputs, admitted rounds and goal-limit stopping', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'sdk-goal-parity-')) + try { + const native = await scenario('native', cwd) + const sdk = await scenario('sdk', cwd) + expect(sdk).toEqual(native) + expect(sdk.requests).toHaveLength(3) + expect(sdk.changes).toEqual(['create', 'block']) + expect(sdk.rounds).toEqual([ + { round: 1, revision: 1, currentGoal: true }, { round: 2, revision: 1, currentGoal: true }, + ]) + expect(sdk.turns).toEqual([1, 2, 3]) + expect(sdk.permissionEvents).toEqual([]) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/sdk/server/tests/plan-parity.spec.ts b/packages/sdk/server/tests/plan-parity.spec.ts index 226502fe85..69f243ff4b 100644 --- a/packages/sdk/server/tests/plan-parity.spec.ts +++ b/packages/sdk/server/tests/plan-parity.spec.ts @@ -6,7 +6,6 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import CommandRuntime from '@deepseek-ai/dsh-commands' -import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import PlanMode from '@deepseek-ai/dsh-plan-mode' import { SessionId } from '@deepseek-ai/dsh-session' import UserQuestions, { UserQuestionError } from '@deepseek-ai/dsh-user-questions' @@ -14,28 +13,11 @@ import type { AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions' import { expect, it } from 'vitest' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { HarnessSdkJsonRpcServer } from '../src/server.ts' +import { modelInput } from './command-model-input.ts' const PLAN = '# Native plan\n\n- Inspect.\n- Implement after approval.' const GUIDANCE = 'You are in plan mode. Do not edit or write files. Present the plan through exit_plan_mode.' -function modelInput(options: GenerateOptions) { - return structuredClone({ - provider: options.provider, model: options.model, system: options.system, - messages: options.messages.map(message => ({ role: message.role, content: message.content })), - tools: options.tools, - toolHistory: options.toolHistory === undefined ? undefined : { - tools: options.toolHistory.tools, - updates: options.toolHistory.updates.map(({ messageId, additions }) => { - const messageIndex = options.messages.findIndex(message => message.id === messageId) - expect(messageIndex).toBeGreaterThanOrEqual(0) - return { messageIndex, additions } - }), - }, - maxTokens: options.maxTokens, - reasoningEffort: options.reasoningEffort, temperature: options.temperature, stop: options.stop, - }) -} - async function scenario(path: 'sdk' | 'native', outcome: 'approve' | 'dismiss', cwd: string) { const ctx = new Context() let server: HarnessSdkJsonRpcServer | undefined diff --git a/packages/sdk/server/tests/query-input.spec.ts b/packages/sdk/server/tests/query-input.spec.ts index 06dbc9dadf..ff0141a0a0 100644 --- a/packages/sdk/server/tests/query-input.spec.ts +++ b/packages/sdk/server/tests/query-input.spec.ts @@ -21,13 +21,16 @@ describe('SDK query text recognition', () => { ['/plan', '/plan'], ['/计划', '/plan'], [' \n/plan off\t ', '/plan off'], ['/计划\nDesign the change', '/plan\nDesign the change'], ['/plan off extra', '/plan off extra'], + ['/goal', '/goal'], ['/目标', '/goal'], ['/目标\nfinish the task', '/goal\nfinish the task'], + ['/goal edit new objective', '/goal edit new objective'], ['/goal pause', '/goal pause'], + ['/goal resume', '/goal resume'], ['/goal clear', '/goal clear'], ])('recognizes the Desktop form %j', (text, expected) => { expect(queryCommand(text, commands)).toBe(expected) }) it.each(['ordinary text', '/', '/unknown', '/usr/bin', '/COMPACT', '/模型', '/文件', '/compact extra', '/压缩 extra', '/export path', '/model extra', '/file path', - '/plans inspect', '/goal finish', '/反馈 problem', + '/plans inspect', '/goals finish', '/反馈 problem', '/permission', ' \n/permission \t', '/权限', '/权限\n\t', '/permissions read-only', '/导出', '/export', '/model', '/file'])('leaves %j as ordinary text', (text) => { expect(queryCommand(text, commands)).toBeUndefined() @@ -43,7 +46,8 @@ describe('SDK query text recognition', () => { }) it('leaves other registered commands and unavailable supported commands unchanged', () => { - expect(queryCommand('/goal', commands)).toBeUndefined() + expect(queryCommand('/feedback', commands)).toBeUndefined() + expect(queryCommand('/goal', [])).toBeUndefined() expect(queryCommand('/plan', [])).toBeUndefined() expect(queryCommand('/compact', [])).toBeUndefined() expect(queryCommand('/export', [])).toBeUndefined() @@ -54,6 +58,7 @@ describe('SDK query text recognition', () => { .toBeUndefined() expect(queryCommand('/计划', [{ name: 'planning', definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), description: 'Renamed plan' }])) .toBeUndefined() + expect(queryCommand('/目标', [{ name: 'goal', description: 'Scoped goal' }])).toBeUndefined() }) it('preserves exact-name priority over localized aliases', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45fea86824..3584e794c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13411,12 +13411,21 @@ importers: '@deepseek-ai/dsh-command-compact': specifier: workspace:* version: link:../../compaction/command-compact + '@deepseek-ai/dsh-command-goal': + specifier: workspace:* + version: link:../../goal/command-goal '@deepseek-ai/dsh-commands': specifier: workspace:* version: link:../../interaction/commands '@deepseek-ai/dsh-compaction-basic': specifier: workspace:* version: link:../../compaction/compaction-basic + '@deepseek-ai/dsh-goal': + specifier: workspace:* + version: link:../../goal/goal + '@deepseek-ai/dsh-goal-round-driver': + specifier: workspace:* + version: link:../../goal/goal-round-driver '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -13459,6 +13468,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:* + version: link:../../goal/tool-goal '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 4e0b0412ac..ec94bd5f66 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -12,8 +12,8 @@ en: c4c30913fe2642ef zh: 0ed42bca37fba60b /deepseek-harness-python-sdk/query-and-response: - en: 1348c26311dee604 - zh: 9290f1d66da1781c + en: c9e73f3b9934032f + zh: bf44fb5d471dbe75 /deepseek-harness-python-sdk/client-approvals: en: fc69be1beb8abff1 zh: 91b56781931a6f68 @@ -24,5 +24,5 @@ en: 979ad00fe498b2be zh: 4b656bd268292a13 /deepseek-harness-python-sdk/results-and-notifications: - en: d4f7366c2d260639 - zh: 7de99aed2b1bdd0a + en: e3318c54ef33e859 + zh: 048d2db7bcf5399f diff --git a/python/sdk/README.md b/python/sdk/README.md index 661463423b..fb7c480e12 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -61,18 +61,20 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: ## Query and response -Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The full SDK intercepts bare `/compact`, `/permission ` with a non-blank preset, and `/plan [off|message]`, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/model` and other input remain ordinary prompts. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command. +Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The full SDK intercepts bare `/compact`, `/permission ` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/model` and other input remain ordinary prompts. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command. `receive_response()` claims the next query in send order. It first waits for the routing response, then yields `Notification` objects followed by one terminal value: `RunResult` for the prompt's inbox-receipt-to-idle interval, or `CommandExecution` after the command settles. Notifications can accumulate before receiving begins. Separate receivers can observe their queries concurrently, and another `query()` can be sent while a receiver waits. Close an unfinished iterator to release only its local observation; this does not cancel or roll back runtime work. Resources are released before yielding the terminal value. `CommandExecution` contains `command_id` and a `result` discriminated by `kind`. Success may include `text` and `source_event_seq`; an error includes `text`. Handler errors return `kind="error"`; routing, protocol and transport failures raise during receiving. Sending failures raise from `query()`. `request_timeout_seconds` bounds waiting for the routing response, not later prompt activity; an already-arrived response remains receivable after that deadline. -The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` strictly accepts `/compact`, `/permission `, or `/plan [off|message]` and rejects bare `/permission`. See the [compaction example](examples/README.md#compact-a-full-sdk-session). +The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` strictly accepts `/compact`, `/permission `, `/plan [off|message]`, or `/goal [objective|clear|edit objective|pause|resume]` and rejects bare `/permission`. See the [compaction example](examples/README.md#compact-a-full-sdk-session). `/permission ` delegates to the native permission handler. An unknown preset returns `CommandExecution.result.kind="error"` without changing permission state. A successful change belongs to that Session: later queries and restoration retain it, other Sessions retain their own state, and new Sessions use configured defaults. `DeepSeekHarnessConfig` remains startup input, not a shared live state mirror; the command does not rewrite it or saved user defaults. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions). `/plan` enters plan mode, `/plan off` leaves, and `/plan ` enters then submits the trimmed message through native steer. Its `CommandExecution` confirms command completion, not completion of planning or review. To collect a normal activity result, enter with `/plan` then submit a separate ordinary query. For combined input, subscribe before sending and use `session/wait` while keeping the question handler alive, as in the [plan example](examples/README.md#review-a-plan). Plan mode changes guidance and progressively admits `exit_plan_mode`; it does not change permission settings. The default guidance prohibits file edits, including plan documents, while execution remains governed by the sandbox. +`/goal` shows the current goal; `/goal `, `edit `, `pause`, `resume`, and `clear` use the native goal handler. Creation and resume can start automatic rounds after the command returns. Subscribe before sending; observe terminal `goal/change` operations, then call `session/wait` for any closing model reply. Idle between rounds is not goal completion. A restored active goal stays disarmed until explicit resume. Goal commands preserve permission settings and startup configuration. See the [goal example](examples/README.md#run-a-goal). + ## Client approvals @@ -133,6 +135,8 @@ Callbacks run on separate threads so the reader can process cancellation and oth `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`. +`NotificationSubscription.next(timeout_seconds=...)` bounds one local notification wait and raises `TimeoutError` on expiry. Omitting the argument waits indefinitely. A timeout neither cancels runtime work nor closes the subscription; a later call can receive subsequent notifications. + `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, and every profile-owned durable resource. The full `sdk` profile uses its credentials, settings, and session stores; `sdk-minimal` uses JSONL session storage and stores images under `$DSH_HOME/attachments/v1`, with request-image cache under `$DSH_HOME/cache/attachments`. 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. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index e39a4cdf1b..120a10c697 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -61,18 +61,20 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: ## 查询与接收 -调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。完整 SDK 接管裸 `/compact`、带非空 preset 的 `/permission ` 和 `/plan [off|message]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 及其余输入仍是普通提示词。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。 +调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。完整 SDK 接管裸 `/compact`、带非空 preset 的 `/permission `、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 及其余输入仍是普通提示词。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。 `receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。 `CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。 -完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 严格接受 `/compact`、`/permission ` 或 `/plan [off|message]`,并拒绝裸 `/permission`。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 +完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 严格接受 `/compact`、`/permission `、`/plan [off|message]` 或 `/goal [objective|clear|edit objective|pause|resume]`,并拒绝裸 `/permission`。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。 `/permission ` 委托给原生权限 handler。未知 preset 返回 `CommandExecution.result.kind="error"`,不改变权限状态。成功的变更属于该 Session:后续 query 和恢复保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。`DeepSeekHarnessConfig` 仍是启动输入,不是共享的实时状态镜像;命令不重写它或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外模型 step。另见[权限示例](examples/README.zh.md#change-session-permissions)。 `/plan` 进入计划模式,`/plan off` 退出,`/plan ` 进入后通过原生 steer 提交去除首尾空白的正文。返回的 `CommandExecution` 只表示命令完成,不表示规划或审查结束。需要普通活动结果时,可以先用 `/plan` 进入,再单独提交普通 query。组合输入则应在发送前订阅,并在保持问答处理器运行的同时使用 `session/wait`,见[计划示例](examples/README.zh.md#review-a-plan)。计划模式改变指导并渐进披露 `exit_plan_mode`,不修改权限设置。默认指导禁止编辑文件,包括计划文档;实际执行仍由沙箱控制。 +`/goal` 显示当前目标;`/goal `、`edit `、`pause`、`resume` 和 `clear` 使用原生目标 handler。创建和恢复可在命令返回后启动自动轮次。发送前应订阅通知,观察终态 `goal/change` 操作,再调用 `session/wait` 等待可能的模型收尾回复。轮次之间的 idle 不代表目标完成。恢复的活动目标保持 disarmed,直到显式恢复。目标命令保留权限设置和启动配置。另见[目标示例](examples/README.zh.md#run-a-goal)。 + ## 客户端审批 @@ -133,6 +135,8 @@ with DeepSeekHarness( `Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 +`NotificationSubscription.next(timeout_seconds=...)` 限制单次本地通知等待,超时抛出 `TimeoutError`;省略参数则无限等待。超时不取消运行时工作,也不关闭订阅,后续调用仍可接收通知。 + `HarnessClient` 会在运行时进程的整个生命周期内保留已发现的 subagent 谱系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。 所选 home 保存 profile、插件与每个 profile 自有的持久资源。完整 `sdk` profile 使用其中的凭据、设置与会话存储;`sdk-minimal` 保存 JSONL 会话,将图片存储在 `$DSH_HOME/attachments/v1` 下,请求图片缓存位于 `$DSH_HOME/cache/attachments` 下。需要隔离这些资源时应使用新的 home;独立工作应使用新的会话 ID。同时复用 harness 与会话 ID 会延续持久对话和会话资源。 diff --git a/python/sdk/examples/README.i18n.yaml b/python/sdk/examples/README.i18n.yaml index a155e469c8..45cc6d2c04 100644 --- a/python/sdk/examples/README.i18n.yaml +++ b/python/sdk/examples/README.i18n.yaml @@ -15,8 +15,11 @@ en: 3471642d8444d0e9 zh: 7831f28295a6e68a /python-sdk-example/review-a-plan: - en: b013d5ab5e24bb83 - zh: 84ed4333970b9dc4 + en: bd0da5138a033eba + zh: f291c3ae8967e936 +/python-sdk-example/run-a-goal: + en: ae73e9fd9d402047 + zh: f9694bc6a6e12929 /python-sdk-example/add-plugins: en: 5e1e30dac13f6c9e zh: 0af98ebb75c88f43 diff --git a/python/sdk/examples/README.md b/python/sdk/examples/README.md index eb602b5a89..6f43ac535e 100644 --- a/python/sdk/examples/README.md +++ b/python/sdk/examples/README.md @@ -70,6 +70,20 @@ python python/sdk/examples/plan.py \ The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait`; its question callback is independent of the protocol reader. + +## Run a goal + +The [goal example](goal.py) submits `/goal `, prints goal changes and assistant replies, and waits for the final reply after a terminal goal event. It uses the full SDK profile and workspace-write permissions. + +```sh +python python/sdk/examples/goal.py \ + --dsh-home /absolute/path/to/goal-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + "Calculate 17 * 19, verify the result, and complete the goal without changing files." +``` + +`--timeout-seconds` bounds observation (300 seconds by default). A timeout reports failure and closes the runtime; it does not mark the goal complete. The persisted goal can be inspected after restoration and explicitly resumed. `session/wait` alone is insufficient because automatic rounds have idle gaps. + ## Add plugins Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes: diff --git a/python/sdk/examples/README.zh.md b/python/sdk/examples/README.zh.md index e5cb364b7a..4ca66cac69 100644 --- a/python/sdk/examples/README.zh.md +++ b/python/sdk/examples/README.zh.md @@ -70,6 +70,20 @@ python python/sdk/examples/plan.py \ 原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,问答回调独立于协议读取线程。 + +## 运行目标 + +[目标示例](goal.py) 提交 `/goal `,打印目标变化和助手回复,并在终态目标事件后等待最后的回复。示例使用完整 SDK profile 和 workspace-write 权限。 + +```sh +python python/sdk/examples/goal.py \ + --dsh-home /absolute/path/to/goal-dsh-home \ + --workspace /absolute/path/to/disposable-workspace \ + "Calculate 17 * 19, verify the result, and complete the goal without changing files." +``` + +`--timeout-seconds` 限制观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。自动轮次之间存在 idle 间隙,因此仅调用 `session/wait` 不足以等待目标结束。 + ## 添加插件 对同一个显式 home 使用运行时 wheel 包提供的 `dsh` 命令,以进行持久 profile 变更: diff --git a/python/sdk/examples/goal.py b/python/sdk/examples/goal.py new file mode 100644 index 0000000000..59676b3892 --- /dev/null +++ b/python/sdk/examples/goal.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Set a goal and observe automatic rounds through the final model reply.""" + +from __future__ import annotations + +import argparse +import os +import time +from pathlib import Path + +from pydantic import BaseModel + +from deepseek_harness import CommandExecution, DeepSeekHarness, Notification +from deepseek_harness.api import final_response + + +class WaitResult(BaseModel): + """The empty session/wait result.""" + + +def display(notification: Notification) -> dict | None: + """Print durable assistant output and return the observed root Session event.""" + if notification.method != "session.event": + return None + event = notification.payload["event"] + if event["type"] == "assistant/message": + text = final_response([event]) + if text: + print(text, flush=True) + elif event["type"] == "goal/change": + print(f"Goal: {event['data']['operation']}", flush=True) + elif event["type"] == "turn/end" and event["data"]["reason"]["kind"] != "completed": + raise RuntimeError(f"Goal activity ended: {event['data']['reason']}") + return event + + +def main() -> None: + """Observe goal state before waiting for the final activity interval.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dsh-home", required=True, type=Path) + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) + parser.add_argument("--timeout-seconds", type=float, default=300) + parser.add_argument("objective", help="Objective for the native /goal command") + args = parser.parse_args() + if not args.objective.strip() or args.timeout_seconds <= 0: + parser.error("objective must be non-blank and timeout-seconds must be positive") + with DeepSeekHarness( + dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()), + profile="sdk", model=args.model, env={"DSH_PERMISSION_MODE": "workspace-write"}, + ) as harness: + session = harness.start_session() + with harness.client.subscribe_notifications(lambda notice: notice.payload.get("sessionId") == session.id) as notifications: + deadline = time.monotonic() + args.timeout_seconds + session.query(f"/goal {args.objective}") + for message in session.receive_response(): + if isinstance(message, CommandExecution): + print(message.result.text, flush=True) + if message.result.kind == "error": + raise RuntimeError(message.result.text) + while True: + event = display(notifications.next(timeout_seconds=max(0, deadline - time.monotonic()))) + if event is not None and event["type"] == "goal/change" and event["data"]["operation"] in { + "complete", "block", "pause", "clear", + }: + break + # A terminal goal event can precede the model's closing reply. + harness.client.request( + "session/wait", {"sessionId": session.id}, response_model=WaitResult, + timeout_seconds=max(0, deadline - time.monotonic()), + on_notification=display, notification_subscription=notifications, + ) + + +if __name__ == "__main__": + main() diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 1971077438..91a65b977c 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -720,8 +720,12 @@ class NotificationSubscription: self.close() self._notifications.put(error) - def next(self) -> Notification: - item = self._notifications.get() + def next(self, timeout_seconds: float | None = None) -> Notification: + """Read one notification; an optional timeout stops only this local wait.""" + try: + item = self._notifications.get(timeout=timeout_seconds) + except queue.Empty as exc: + raise TimeoutError("timed out waiting for an SDK notification") from exc if isinstance(item, BaseException): raise item return item diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index bdc404cf4d..1c2e6c821a 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -596,6 +596,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe assert notification.payload["sessionId"] == "other" +def test_notification_timeout_keeps_subscription_available_for_later_events() -> None: + client = HarnessClient() + with client.subscribe_notifications() as subscription: + with pytest.raises(TimeoutError, match="timed out waiting for an SDK notification"): + subscription.next(timeout_seconds=0) + client._handle_message({"jsonrpc": "2.0", "method": "tick", "params": {"value": 1}}) + assert subscription.next(timeout_seconds=0).payload == {"value": 1} + + def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None: client = HarnessClient() with client.subscribe_session_notifications("main") as first: diff --git a/python/sdk/tests/test_commands.py b/python/sdk/tests/test_commands.py index 5c9628530b..7e221432d9 100644 --- a/python/sdk/tests/test_commands.py +++ b/python/sdk/tests/test_commands.py @@ -103,7 +103,7 @@ def test_low_level_command_preserves_the_line(peer: Path, tmp_path: Path) -> Non def test_command_rpc_rejection_does_not_fall_back_to_prompt(peer: Path, tmp_path: Path) -> None: with harness(peer, tmp_path, {"error": {"code": -32602, "message": "unsupported command"}}) as sdk: with pytest.raises(JsonRpcError, match="unsupported command") as raised: - sdk.client.session_command("main", "/goal") + sdk.client.session_command("main", "/model") assert raised.value.code == -32602 assert [frame["method"] for frame in frames(tmp_path)] == ["initialize", "session/command", "shutdown"] diff --git a/python/sdk/tests/test_commands_runtime.py b/python/sdk/tests/test_commands_runtime.py index 733e5da5ba..ad24494dd0 100644 --- a/python/sdk/tests/test_commands_runtime.py +++ b/python/sdk/tests/test_commands_runtime.py @@ -40,7 +40,8 @@ _T = TypeVar("_T") class _Completion: text: str blocked: bool = False - tool_call: tuple[str, dict] | None = None + tool_call: tuple[str, dict] | Callable[[dict], tuple[str, dict]] | None = None + allow_disconnect: bool = False entered: threading.Event = field(default_factory=threading.Event) release: threading.Event = field(default_factory=threading.Event) @@ -98,13 +99,18 @@ class _ModelServer: completion.entered.set() # The test owns release. This larger bound only contains a failed test. assert completion.release.wait(_WAIT_SECONDS * 2), "model barrier was not released" - payload = _messages_response(completion.text, body["model"], ordinal, completion.tool_call) - self.send_response(200) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - self.wfile.flush() + tool_call = completion.tool_call(body) if callable(completion.tool_call) else completion.tool_call + payload = _messages_response(completion.text, body["model"], ordinal, tool_call) + try: + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + if not completion.allow_disconnect: + raise def log_message(self, _format: str, *_args: object) -> None: pass @@ -182,6 +188,7 @@ def _runtime( tmp_path: Path, completions: list[_Completion], *, capture_patch: Path | None = None, initial_permission: str = "danger-full-access", on_user_question: UserQuestionHandler | None = None, + max_goal_rounds: int | None = None, ) -> Iterator[_Runtime]: workspace = tmp_path / "workspace" workspace.mkdir(exist_ok=True) @@ -194,6 +201,9 @@ def _runtime( + "\n compression: none\n", encoding="utf-8", ) + if max_goal_rounds is not None: + with patch.open("a", encoding="utf-8") as extra: + extra.write(f"- id: goal\n config:\n defaultMaxGoalRounds: {max_goal_rounds}\n") patches = (patch,) if capture_patch is None else (patch, capture_patch.resolve()) dsh_bin = _dsh_bin() with _ModelServer(completions) as model, ThreadPoolExecutor(max_workers=2) as workers: @@ -456,10 +466,10 @@ def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_pa assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2 -def test_unimplemented_slash_remains_plain_text_without_creating_a_goal(tmp_path: Path) -> None: - input_text = "/goal DSH_GOAL_TEXT_INPUT" +def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_path: Path) -> None: + input_text = "/feedback DSH_FEEDBACK_TEXT_INPUT" following = "DSH_AFTER_PLAN_TEXT_INPUT" - reply_before = "DSH_GOAL_TEXT_REPLY" + reply_before = "DSH_FEEDBACK_TEXT_REPLY" reply = "DSH_AFTER_PLAN_TEXT_REPLY" with _runtime(tmp_path, [_Completion(reply_before), _Completion(reply)]) as runtime: session = runtime.harness.start_session(_SESSION_ID) @@ -478,7 +488,7 @@ def test_unimplemented_slash_remains_plain_text_without_creating_a_goal(tmp_path assert initial["tools"] == continued["tools"] events = _read_events(runtime.logs) assert not _of_type(events, "command/run") - assert not any(event["type"].startswith("goal/") for event in events) + assert not any(event["type"].startswith("feedback/") for event in events) assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2 diff --git a/python/sdk/tests/test_goal_runtime.py b/python/sdk/tests/test_goal_runtime.py new file mode 100644 index 0000000000..7d1644adee --- /dev/null +++ b/python/sdk/tests/test_goal_runtime.py @@ -0,0 +1,177 @@ +"""Native goal rounds and state changes through the Python SDK profile.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +from deepseek_harness import CommandExecution, RunResult, Session +from deepseek_harness.client import NotificationSubscription +from test_commands_runtime import ( + _Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission, + _command_pair, _of_type, _permission_facts, _read_events, _receive_result, + _request_texts, _runtime, _text_blocks, +) +from test_plan_runtime import _WaitResult, _wait_activity + + +def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution: + runtime.call(session.query, text) + result = runtime.call(_receive_result, session) + assert isinstance(result, CommandExecution) + assert result.result.kind == kind, result.result.text + return result + + +def _until(subscription: NotificationSubscription, event_type: str, operation: str | None = None) -> dict: + deadline = time.monotonic() + _WAIT_SECONDS + while True: + notification = subscription.next(timeout_seconds=max(0, deadline - time.monotonic())) + if notification.method != "session.event": + continue + event = notification.payload["event"] + if event["type"] == event_type and (operation is None or event["data"].get("operation") == operation): + return event["data"] + + +def _complete_goal(request: dict) -> tuple[str, dict]: + results = [block for message in request["messages"] for block in message["content"] + if block["type"] == "tool_result"] + current = json.loads("\n".join(_text_blocks(results[-1]["content"]))) + goal = current["goal"] + assert goal["phase"] == "active" and current["activation"] == "armed" + return "update_goal", {"goal_id": goal["id"], "revision": goal["revision"], "action": "complete"} + + +def _completion_steps(final: _Completion | None = None) -> list[_Completion]: + return [_Completion("", tool_call=("get_goal", {})), _Completion("", tool_call=_complete_goal), + final if final is not None else _Completion("GOAL_DONE")] + + +def test_goal_reads_and_invalid_controls_do_not_start_model_work(tmp_path: Path) -> None: + with _runtime(tmp_path, []) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + executions = [_command(runtime, session, "/目标")] + for line in ["/goal edit", "/goal edit Missing goal", "/goal pause", "/goal resume"]: + executions.append(_command(runtime, session, line, kind="error")) + executions.append(_command(runtime, session, "/goal clear")) + assert runtime.model.captured() == [] + events = _read_events(runtime.logs) + assert not _of_type(events, "goal/change") and not _of_type(events, "turn/start") + for execution in executions: + _command_pair(events, execution, "goal") + + +def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path) -> None: + final = _Completion("GOAL_DONE", blocked=True) + with _runtime(tmp_path, [_Completion("PROGRESS"), *_completion_steps(final)], + initial_permission="workspace-write", max_goal_rounds=3) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + with runtime.harness.client.subscribe_session_notifications(session.id) as events: + execution = _command(runtime, session, "/目标 Verify the small change.") + completed = _until(events, "goal/change", "complete") + assert completed["goal"]["phase"] == "complete" and completed["roundsStarted"] == 2 + assert final.entered.wait(_WAIT_SECONDS) + waiting = runtime.workers.submit(runtime.harness.client.request, "session/wait", + {"sessionId": session.id}, response_model=_WaitResult) + assert not waiting.done() and not final.release.is_set() + final.release.set() + waiting.result(timeout=_WAIT_SECONDS) + assert _until(events, "turn/end")["turn"] == 2 + requests = runtime.model.captured() + assert len(requests) == 4 + assert 'Objective: "Verify the small change."\nRound: 1/3' in "\n".join(_request_texts(requests[0])) + assert 'Round: 2/3' in "\n".join(_request_texts(requests[1])) + assert '' in "\n".join(_request_texts(requests[-1])) + for request in requests: + _assert_request_permission(request, "workspace-write", "ask") + assert runtime.harness.config.env["DSH_PERMISSION_MODE"] == "workspace-write" + events = _read_events(runtime.logs) + _command_pair(events, execution, "goal") + assert [e["data"]["turn"] for e in _of_type(events, "turn/start")] == [1, 2] + assert [e["data"] for e in _of_type(events, "step/start")] == [ + {"turn": 1, "step": 1}, {"turn": 2, "step": 1}, {"turn": 2, "step": 2}, {"turn": 2, "step": 3}, + ] + assert len(_permission_facts(events)) == 3 + + +def test_pause_aborts_then_edit_and_resume_use_revised_goal_without_changing_other_sessions(tmp_path: Path) -> None: + first = _Completion("CANCELLED_RESPONSE", blocked=True, allow_disconnect=True) + with _runtime(tmp_path, [first, *_completion_steps(), _Completion("RAW_GOAL_TEXT")]) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + with runtime.harness.client.subscribe_session_notifications(session.id) as events: + _command(runtime, session, "/goal Original objective") + assert first.entered.wait(_WAIT_SECONDS) + _command(runtime, session, "/goal Duplicate objective", kind="error") + _command(runtime, session, "/goal edit", kind="error") + _command(runtime, session, "/goal resume", kind="error") + _command(runtime, session, "/goal PAUSE") + assert _until(events, "turn/end")["reason"]["kind"] == "aborted" + assert not first.release.is_set() + _command(runtime, session, "/goal edit Revised objective") + assert len(runtime.model.captured()) == 1 + other = runtime.harness.start_session("goal-other") + assert "No goal is currently set" in _command(runtime, other, "/goal").result.text + _command(runtime, session, "/goal resume") + _until(events, "goal/change", "complete") + _wait_activity(runtime, session) + assert 'Objective: "Revised objective"' in "\n".join(_request_texts(runtime.model.captured()[1])) + _command(runtime, session, "/goal clear") + _command(runtime, session, "/goal clear") + result = runtime.call(session.run, "/goal Plain text objective") + assert isinstance(result, RunResult) and result.final_response == "RAW_GOAL_TEXT" + assert "/goal Plain text objective" in _request_texts(runtime.model.captured()[-1]) + changes = _of_type(_read_events(runtime.logs), "goal/change") + assert [e["data"]["operation"] for e in changes] == ["create", "pause", "edit", "resume", "complete", "clear"] + assert not _of_type(_read_events(runtime.logs, session_id="goal-other"), "goal/change") + + +def test_clear_keeps_current_request_and_stops_further_goal_rounds(tmp_path: Path) -> None: + first = _Completion("CURRENT_ROUND_FINISHED", blocked=True) + with _runtime(tmp_path, [first]) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + with runtime.harness.client.subscribe_session_notifications(session.id) as events: + _command(runtime, session, "/goal Work until cleared") + assert first.entered.wait(_WAIT_SECONDS) + _command(runtime, session, "/goal clear") + first.release.set() + _wait_activity(runtime, session) + assert _until(events, "turn/end")["reason"]["kind"] == "completed" + assert len(runtime.model.captured()) == 1 + events = _read_events(runtime.logs) + assert len(_of_type(events, "assistant/message")) == 1 + assert [e["data"]["operation"] for e in _of_type(events, "goal/change")] == ["create", "clear"] + + +def test_round_limit_blocks_and_rejects_resume_without_another_request(tmp_path: Path) -> None: + with _runtime(tmp_path, [_Completion("ONE"), _Completion("TWO")], max_goal_rounds=2) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + with runtime.harness.client.subscribe_session_notifications(session.id) as events: + _command(runtime, session, "/goal An unfinished objective") + blocked = _until(events, "goal/change", "block") + assert blocked["goal"]["blockedReason"]["code"] == "round-limit" + assert blocked["roundsStarted"] == 2 + _command(runtime, session, "/goal resume", kind="error") + _wait_activity(runtime, session) + assert len(runtime.model.captured()) == 2 + + +def test_restored_active_goal_waits_for_explicit_resume(tmp_path: Path) -> None: + first = _Completion("INTERRUPTED", blocked=True, allow_disconnect=True) + with _runtime(tmp_path, [first], max_goal_rounds=3) as runtime: + session = runtime.harness.start_session(_SESSION_ID) + runtime.call(runtime.harness.client.session_prompt, session.id, [{"type": "text", "text": "Start an ordinary task"}]) + assert first.entered.wait(_WAIT_SECONDS) + _command(runtime, session, "/goal Persisted objective") + runtime.harness.close() + with _runtime(tmp_path, _completion_steps()) as runtime: + session = runtime.call(runtime.harness.resume_session, _SESSION_ID) + shown = _command(runtime, session, "/goal") + assert "Status: active" in shown.result.text and "Activation: disarmed" in shown.result.text + assert runtime.model.captured() == [] + with runtime.harness.client.subscribe_session_notifications(session.id) as events: + _command(runtime, session, "/goal resume") + _until(events, "goal/change", "complete") + _wait_activity(runtime, session) + assert 'Objective: "Persisted objective"\nRound: 1/3' in "\n".join(_request_texts(runtime.model.captured()[0])) diff --git a/python/sdk/tests/test_queries.py b/python/sdk/tests/test_queries.py index 78459b3698..02256aaa09 100644 --- a/python/sdk/tests/test_queries.py +++ b/python/sdk/tests/test_queries.py @@ -141,7 +141,7 @@ def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) -> assert_released(sdk) -@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/goal", "/permission"]) +@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", "/permission"]) @pytest.mark.parametrize("delivery", ["queue", "steer"]) def test_prompt_yields_receipt_through_idle_then_run_result(sdk: DeepSeekHarness, text: str, delivery: str) -> None: session = sdk.start_session("main") diff --git a/snapshots/sdk/goal-rounds/capture.cordis.yml b/snapshots/sdk/goal-rounds/capture.cordis.yml new file mode 100644 index 0000000000..d39f3a6bae --- /dev/null +++ b/snapshots/sdk/goal-rounds/capture.cordis.yml @@ -0,0 +1,5 @@ +- insert: + - id: goal-request-capture + name: '../manual-compact/capture-requests.mjs' + config: + path: !!js dshHomePath('goal-requests.jsonl') diff --git a/snapshots/sdk/goal-rounds/replay.override.json b/snapshots/sdk/goal-rounds/replay.override.json new file mode 100644 index 0000000000..c98c831974 --- /dev/null +++ b/snapshots/sdk/goal-rounds/replay.override.json @@ -0,0 +1,66 @@ +{ + "patches": [ + { + "at": 3, + "entry": { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "tool-call-delta", + "index": 0, + "id": "commands-tool-3", + "name": "update_goal", + "argumentsDelta": "" + }, + { + "type": "tool-call-delta", + "index": 0, + "id": "commands-tool-3", + "argumentsDelta": "{\"goal_id\": \"{{fromRequest:goal-[0-9a-f-]+}}\", \"revision\": 1, \"action\": \"complete\"}" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "commands-tool-3", + "name": "update_goal", + "arguments": "{\"goal_id\": \"{{fromRequest:goal-[0-9a-f-]+}}\", \"revision\": 1, \"action\": \"complete\"}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 10000, + "outputTokens": 32, + "totalTokens": 10032 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + }, + "replayState": { + "response": { + "kind": "deepseek-messages", + "version": 1, + "model": "deepseek-v4-flash" + }, + "blocks": [ + { + "type": "tool-call" + } + ] + } + } + ] + } + } + ] +} diff --git a/snapshots/sdk/goal-rounds/session.v4.jsonl b/snapshots/sdk/goal-rounds/session.v4.jsonl new file mode 100644 index 0000000000..e035053d1b --- /dev/null +++ b/snapshots/sdk/goal-rounds/session.v4.jsonl @@ -0,0 +1,58 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1792108800000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply SDK_GOAL_READY without tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply SDK_GOAL_READY without tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}],"source":{"kind":"time-context","form":"snapshot","sections":[{"name":"time-context","text":"Time sampled while preparing turn 1, step 1: 2026-10-16T00:00:00+00:00[UTC]\nBrowser time zone for this request: unavailable. Ask the user to clarify otherwise-unqualified dates and times.\nElapsed since the preceding model-visible message: unavailable."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply SDK_GOAL_READY without tools.","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":13}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_GOAL_READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:5}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_GOAL_READY"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_GOAL_READY"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"command/run","data":{"commandId":"{{command:1}}","name":"goal","args":" Verify this task over two rounds without changing files.","source":{"kind":"user"}}} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"{{id:1}}","revision":1,"objective":"Verify this task over two rounds without changing files.","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1792108800000,"updatedAt":1792108800000}} +{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Goal created\nStatus: active\nObjective: Verify this task over two rounds without changing files.\nRounds: 0/256\nActivation: armed\n\nCommands: /goal edit , /goal pause, /goal clear"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Verify this task over two rounds without changing files.\"\nRound: 1/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":1},"role":"user","id":"{{message:6}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Verify this task over two rounds without changing files.\"\nRound: 1/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":1},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":26}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_GOAL_PROGRESS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:7}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_GOAL_PROGRESS"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_GOAL_PROGRESS"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Verify this task over two rounds without changing files.\"\nRound: 2/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":2},"role":"user","id":"{{message:8}}"}]}} +{"type":"turn/start","data":{"turn":3}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":3,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Verify this task over two rounds without changing files.\"\nRound: 2/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":2},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"series"}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":36}} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"commands-tool-2","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}},"id":"{{message:9}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-2","name":"get_goal","args":[""]},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-2","args":["{}"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"commands-tool-2","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":3,"step":1,"callId":"commands-tool-2","name":"get_goal","arguments":"{}"}} +{"type":"tool/result","data":{"turn":3,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"commands-tool-2"},"toolCallId":"commands-tool-2","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":1,\"objective\":\"Verify this task over two rounds without changing files.\",\"phase\":\"active\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"armed\"}"}],"isError":false,"id":"{{message:10}}"}},"sourceEventSeqs":[39],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":3,"step":1}} +{"type":"step/start","data":{"turn":3,"step":2}} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":42}} +{"type":"assistant/message","data":{"turn":3,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"commands-tool-3","name":"update_goal","arguments":"{\"goal_id\": \"{{id:1}}\", \"revision\": 1, \"action\": \"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}},"id":"{{message:11}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-3","name":"update_goal","args":[""]},{"type":"tool-call-chunks","time0":1792108800000,"index":0,"dt":[],"id":"commands-tool-3","args":["{\"goal_id\": \"{{id:1}}\", \"revision\": 1, \"action\": \"complete\"}"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"commands-tool-3","name":"update_goal","arguments":"{\"goal_id\": \"{{id:1}}\", \"revision\": 1, \"action\": \"complete\"}"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":3,"step":2,"callId":"commands-tool-3","name":"update_goal","arguments":"{\"goal_id\": \"{{id:1}}\", \"revision\": 1, \"action\": \"complete\"}"}} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"{{id:1}}","revision":2,"objective":"Verify this task over two rounds without changing files.","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1792108800000,"updatedAt":1792108800000}} +{"type":"tool/result","data":{"turn":3,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"commands-tool-3"},"toolCallId":"commands-tool-3","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":2,\"objective\":\"Verify this task over two rounds without changing files.\",\"phase\":\"complete\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"disarmed\"}"}],"isError":false,"id":"{{message:12}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"Verify this task over two rounds without changing files.\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"tool-goal","form":"notice","summary":"complete: Verify this task over two rounds without changing files."},"role":"user","id":"{{message:13}}"}]}} +{"type":"step/end","data":{"turn":3,"step":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":3,"step":3}} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"Verify this task over two rounds without changing files.\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"tool-goal","form":"notice","summary":"complete: Verify this task over two rounds without changing files."},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} +{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":4,"throughSeq":52}} +{"type":"assistant/message","data":{"turn":3,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"SDK_GOAL_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}},"id":"{{message:14}}"},"usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032},"stream":[{"type":"chunk","time":1792108800000,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1792108800000,"index":0,"dt":[],"texts":["SDK_GOAL_DONE"]},{"type":"chunk","time":1792108800000,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SDK_GOAL_DONE"}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"usage","usage":{"inputTokens":10000,"outputTokens":32,"totalTokens":10032}}},{"type":"chunk","time":1792108800000,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":3,"step":3}} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/goal-rounds/snapshot.yml b/snapshots/sdk/goal-rounds/snapshot.yml new file mode 100644 index 0000000000..bcf47464ee --- /dev/null +++ b/snapshots/sdk/goal-rounds/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: goal-rounds +profile: sdk +composition: sdk-desktop +recording: authored +header: + class: sdk-desktop +replay: + override: true diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index fd8920fe37..199e17486b 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -174,6 +174,12 @@ const SDK_ASSERTIONS: Readonly> = { replayStandaloneCommands: true, planReviews: ['keep', 'approve', 'dismiss'], }, + 'goal-rounds': { + environment: { DSH_PERMISSION_MODE: 'workspace-write' }, + patches: [fileURLToPath(new URL('./goal-rounds/capture.cordis.yml', import.meta.url))], + modelRequestsFile: 'goal-requests.jsonl', + replayStandaloneCommands: true, + }, 'minimal-image-compaction': { expectedFinalResponse: 'MINIMAL_IMAGE_OK', expectedTools: { bash: ['command'], read_image: ['file_path'], working_directory: [] }, @@ -853,7 +859,10 @@ async function runScenario(scenario: CorpusScenario): Promise<{ subscription, sessionId, event => event.type === 'turn/end' && (event.data as JsonObject | undefined)?.turn === action.turn, - observe, + (notification) => { + observe(notification) + if (assertions.replayStandaloneCommands === true) notifications.push(notification) + }, ) continue } @@ -1134,6 +1143,27 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { ) reconcileCatalogCreationTimes(ordered.map(log => log.content), 'validate') const actualContext = contextOf(ordered, cwd) + if (scenario.name === 'goal-rounds') { + const parent = ordered[0] + if (parent === undefined) throw new Error('goal rounds have no persisted Session') + const events = parseSessionLog(parent.content) + expect(events.filter(event => event.type === 'goal/change').map(event => event.data.operation)) + .toEqual(['create', 'complete']) + expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'goal') + .map(event => event.data.source)).toMatchObject([{ round: 1, revision: 1 }, { round: 2, revision: 1 }]) + expect(events.filter(event => event.type === 'turn/start')).toHaveLength(3) + expect(events.filter(event => event.type === 'step/start')).toHaveLength(5) + expect(modelRequests).toHaveLength(5) + expect(JSON.stringify(modelRequests.at(-1)?.messages)).toContain('') + expect(events.findLast(event => event.type === 'assistant/message')) + .toMatchObject({ data: { message: { content: [{ type: 'text', text: 'SDK_GOAL_DONE' }] } } }) + expect(events.filter(event => event.type === 'sandbox/mode').map(event => event.data.mode)).toEqual(['workspace-write']) + expect(events.filter(event => event.type === 'approval/policy').map(event => event.data.policy)).toEqual(['ask']) + expect(notifications.flatMap(notification => { + const event = notificationEvent(notification) + return event?.type === 'goal/change' ? [event] : [] + })).toEqual(events.filter(event => event.type === 'goal/change')) + } if (scenario.name === 'plan-review') { const parent = ordered[0] if (parent === undefined) throw new Error('plan review has no persisted Session')