diff --git a/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.i18n.yaml b/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.i18n.yaml new file mode 100644 index 0000000000..dbe848dca4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-19-sdk-native-user-questions.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.md +/agent-note-sdk-native-user-questions: + en: b506991bd07d87dc + zh: 9f3e1ecb42163d72 +/agent-note-sdk-native-user-questions/problem: + en: b3dbcdb4b5bce2d1 + zh: 60df882dc253fcf5 +/agent-note-sdk-native-user-questions/decision: + en: 0f7b71f0ef58f5f8 + zh: 07bb12acad27d2fc +/agent-note-sdk-native-user-questions/consequences: + en: 46e3a450c6dce54e + zh: 9f99efc6f8d2ab8d +/agent-note-sdk-native-user-questions/alternatives-considered: + en: 9861066ae00e7bde + zh: 0dec00f4e3c692e0 +/agent-note-sdk-native-user-questions/verification: + en: 18c40c34b936f966 + zh: 81bd38dff90a9adb diff --git a/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.md b/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.md new file mode 100644 index 0000000000..b5522a6e7c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.md @@ -0,0 +1,29 @@ +# Agent Note: SDK native user questions + +Status: implemented + +English | [中文](2026-10-19-sdk-native-user-questions.zh.md) + +## Problem + +The SDK can expose the shared `ask_user_question` tool without a Web GUI, but an out-of-process host cannot answer the native user-questions event through the SDK. Publishing the tool alone leaves calls without an answerer. The SDK must carry the interaction while the application chooses its user interface or simulated-user policy. + +## Decision + +The TypeScript and Python clients accept a question callback and advertise it during initialization. The SDK server adapts the existing user-questions seam to a bidirectional RPC only for its exact owned root Agents. Clients without the capability keep native in-process answerers. Tool definitions and the service's live-root and delegation checks remain shared with the desktop composition. + +Question identity is independent of the JSON-RPC envelope so cancellation can identify outstanding host work. The runtime validates a complete answer batch, aborts pending requests when the tool or server stops, and ignores late responses. The host owns answer collection; the SDK neither chooses answers nor treats missing input as approval. The [protocol reference](../../../../packages/sdk/protocol/README.md#native-user-questions) owns wire details. + +## Consequences + +A GUI and runtime patch module are unnecessary. A host can use the same interface for a terminal, browser, or simulated user. The Python SDK keeps its synchronous API by running callbacks outside the reader thread; callbacks must observe cancellation and release their own resources. Late callbacks cannot publish into a later runtime process. Question waits are process-local; completed tool results retain ordinary Session persistence. + +A Web Session controller is not an interaction provider for the SDK. Reminder restoration uses the SDK's own Session owner and initialized model options; it does not claim a second write handle through a GUI controller. + +## Alternatives considered + +Requiring a Web GUI or mounting its controller couples headless execution to another lifecycle owner. Asking each host to inject a runtime patch repeats the transport bridge outside the maintained SDK. Removing the shared question tool would reduce the model's available capabilities rather than supply its missing answer path. + +## Verification + +Server tests cover ownership, native fallback, answer validation, cancellation, and transport failure. Client tests cover concurrent bidirectional transport and cancellation. Python tests also cover reentrant requests and a late callback after process replacement. Real shipped-profile tests drive the built-in question in standard and PTC modes and close a runtime while the host is answering. The [server tests](../../../../packages/sdk/server/tests/user-questions.spec.ts), [client tests](../../../../packages/sdk/client/tests/user-questions.spec.ts), [Python tests](../../../../python/sdk/tests/test_user_questions.py), and [profile tests](../../../../apps/cli/tests/profiles/sdk/user-questions.e2e.ts) retain this evidence. diff --git a/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.zh.md b/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.zh.md new file mode 100644 index 0000000000..6b26d8cb72 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.zh.md @@ -0,0 +1,29 @@ +# Agent Note: SDK 原生用户问答 + +Status: implemented + +[English](2026-10-19-sdk-native-user-questions.md) | 中文 + +## 问题 + +SDK 可以在没有 Web GUI 时暴露共享的 `ask_user_question` 工具,但进程外宿主无法通过 SDK 回答原生用户问答事件。仅发布工具会使调用缺少回答方。SDK 必须承载交互,应用则自行选择用户界面或模拟用户策略。 + +## 决策 + +TypeScript 与 Python 客户端接收问答回调,并在初始化时声明。SDK server 仅为其确切拥有的根 Agent,把现有用户问答 seam 适配为双向 RPC。未声明能力的客户端保留原生进程内回答方。工具定义以及服务对存活根 Agent 和委派关系的检查仍与桌面端组合共享。 + +问题标识独立于 JSON-RPC 帧,使取消能够定位待决的宿主工作。运行时校验完整答案批次,在工具或服务器停止时中止待决请求,并忽略晚到响应。宿主拥有答案收集过程;SDK 不选择答案,也不把缺失输入当作批准。[协议参考](../../../../packages/sdk/protocol/README.zh.md#native-user-questions)拥有具体协议说明。 + +## 影响 + +此流程不要求 GUI 或运行时补丁模块。宿主可以用同一接口接入终端、浏览器或模拟用户。Python SDK 在读取线程之外执行回调,以保留同步 API;回调必须响应取消并释放自己的资源。晚到回调无法向后续新运行时进程发送结果。问题等待仅属于当前进程;已完成工具结果仍通过普通 Session 路径持久化。 + +Web Session controller 不能充当 SDK 的交互提供方。提醒恢复使用 SDK 自身的 Session 所有者和初始化模型选项,不通过 GUI 控制器争用第二个写入 handle。 + +## 已考虑的替代方案 + +要求 Web GUI 或挂载其控制器,会将 headless 执行与另一个生命周期所有者耦合。要求各宿主注入运行时补丁,会在受维护的 SDK 外重复实现传输适配。移除共享问答工具只会减少模型可用能力,无法提供缺失的回答通道。 + +## 验证 + +服务器测试覆盖所有权、原生回退、答案校验、取消和传输失败。客户端测试覆盖并发双向传输和取消。Python 测试还覆盖回调内再次请求,以及进程替换后的晚到回调。真实发行 profile 测试在标准模式和 PTC 模式调用内建问答,并在宿主回答期间关闭运行时。[服务器测试](../../../../packages/sdk/server/tests/user-questions.spec.ts)、[客户端测试](../../../../packages/sdk/client/tests/user-questions.spec.ts)、[Python 测试](../../../../python/sdk/tests/test_user_questions.py)和 [profile 测试](../../../../apps/cli/tests/profiles/sdk/user-questions.e2e.ts)保留这些依据。 diff --git a/apps/cli/tests/profiles/sdk/fixtures/messages-response.ts b/apps/cli/tests/profiles/sdk/fixtures/messages-response.ts new file mode 100644 index 0000000000..ba38769bae --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/messages-response.ts @@ -0,0 +1,10 @@ +/** Frame one text or tool response from the local Messages endpoint. */ +export function messagesResponse(content: Record, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string { + return [ + { type: 'message_start', message: { id: 'sdk-smoke-response', model: 'deepseek-v4-pro', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: content }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('') +} diff --git a/apps/cli/tests/profiles/sdk/fixtures/schedule-clock.mjs b/apps/cli/tests/profiles/sdk/fixtures/schedule-clock.mjs new file mode 100644 index 0000000000..b24d52b6e7 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/schedule-clock.mjs @@ -0,0 +1,10 @@ +/** Freeze only the isolated runtime's wall clock before reminder activation starts. */ +export function apply(ctx) { + const original = Date.now + const now = Number(process.env.DSH_TEST_SCHEDULE_NOW) + ctx.effect(() => { + Date.now = () => now + return () => { Date.now = original } + }) + ctx.provide('sdkTestClock', { ready: true }) +} diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index c86fdc8337..87ccfa1b88 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -10,6 +10,8 @@ import { execa } from 'execa' import { describe, expect, it, onTestFinished } from 'vitest' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' +import { messagesResponse } from './fixtures/messages-response.ts' + const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) const launch = resolveExampleLaunch({ srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), @@ -17,16 +19,6 @@ const launch = resolveExampleLaunch({ }) const decompress = promisify(zstdDecompress) -/** Frame one text or tool response from the local Messages endpoint. */ -function messagesResponse(content: Record, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string { - return [ - { type: 'message_start', message: { id: 'sdk-smoke-response', model: 'deepseek-v4-pro', usage: { input_tokens: 3, output_tokens: 0 } } }, - { type: 'content_block_start', index: 0, content_block: content }, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } }, - { type: 'message_stop' }, - ].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('') -} function waitForLine( lines: string[], diff --git a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts new file mode 100644 index 0000000000..46b880cb78 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts @@ -0,0 +1,72 @@ +/** Reminder activation must share SDK ownership and initialized model limits after process restart. */ +import { createServer } from 'node:http' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' +import { expect, it, onTestFinished, vi } from 'vitest' +import { messagesResponse } from './fixtures/messages-response.ts' + +it('delivers an overdue reminder with the SDK route and lets the client resume that same Session', async () => { + const root = await mkdtemp(join(tmpdir(), 'sdk-schedule-resume-')) + const patch = join(root, 'clock.patch.yml') + const fixture = fileURLToPath(new URL('./fixtures/schedule-clock.mjs', import.meta.url)) + await writeFile(patch, [ + '- insert:', ' - id: sdk-test-clock', ` name: ${JSON.stringify(fixture)}`, + '- id: sdk-jsonrpc-server', ' inject: [sdkAppStartup, loader, sdkTestClock]', '', + ].join('\n')) + const requests: Record[] = [] + const restored: Record[] = [] + let restarting = false + const server = createServer((incoming, response) => { + let body = '' + incoming.setEncoding('utf8') + incoming.on('data', (chunk: string) => { body += chunk }) + incoming.on('end', () => { + const request = JSON.parse(body) as Record + requests.push(request) + if (restarting) restored.push(request) + const content = requests.length === 1 ? { + type: 'tool_use', id: 'create-reminder', name: 'schedule_create', + input: { title: 'Check SDK', prompt: 'Report the SDK reminder', after_seconds: 86400 }, + } : { type: 'text', text: 'ready' } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(content, requests.length === 1 ? 'tool_use' : 'end_turn')) + }) + }) + const clients: DeepSeekHarness[] = [] + onTestFinished(async () => { + try { await Promise.all(clients.map(client => client.close())) } finally { + try { + await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + } finally { await rm(root, { recursive: true, force: true }) } + } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no port') + const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } + clearAmbientProxyEnv(env) + const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch], + model: 'deepseek-v4-pro', maxTokens: 5678, initializeTimeoutMs: 30_000, + env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-01T00:00:00Z')) } }) + clients.push(first) + expect((await first.session('saved').run('Set a reminder')).finalResponse).toBe('ready') + expect(requests).toHaveLength(2) + await first.close() + restarting = true + const second = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch], + model: 'deepseek-v4-flash', maxTokens: 1234, initializeTimeoutMs: 30_000, + env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-03T00:00:00Z')) } }) + clients.push(second) + await second.start() + await vi.waitFor(() => { expect(restored).toHaveLength(1) }, { timeout: 30_000 }) + expect(restored[0]).toMatchObject({ model: 'deepseek-v4-flash', max_tokens: 1234 }) + const session = await second.resumeSession('saved') + expect((await session.run('Continue after the reminder')).finalResponse).toBe('ready') +}) diff --git a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts new file mode 100644 index 0000000000..3185cf4aa9 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts @@ -0,0 +1,84 @@ +/** Keyless SDK questions through the shipped profile, native tools, and both tool-call modes. */ +import { createServer } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client' +import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' +import { expect, it, onTestFinished } from 'vitest' +import { messagesResponse } from './fixtures/messages-response.ts' + +it.each([ + { mode: 'native', cancel: false }, { mode: 'ptc', cancel: false }, { mode: 'native', cancel: true }, +])('handles a built-in question through $mode (cancel: $cancel)', async ({ mode, cancel }) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-question-profile-')) + const requests: Record[] = [] + const questions = [{ id: 'format', question: 'Which format?', options: [{ label: 'CSV' }, { label: 'JSON' }] }] + const server = createServer((incoming, response) => { + let body = '' + incoming.setEncoding('utf8') + incoming.on('data', (chunk: string) => { body += chunk }) + incoming.on('end', () => { + requests.push(JSON.parse(body) as Record) + const tool = mode === 'ptc' ? { + type: 'tool_use', id: 'question-call', name: 'run_code', input: { + code: `return await tools.ask_user_question(${JSON.stringify({ questions })});`, description: 'Ask which output format to use', + }, + } : { type: 'tool_use', id: 'question-call', name: 'ask_user_question', input: { questions } } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(requests.length === 1 ? tool : { type: 'text', text: 'answer received' }, + requests.length === 1 ? 'tool_use' : 'end_turn')) + }) + }) + const state: { harness?: DeepSeekHarness } = {} + onTestFinished(async () => { + try { await state.harness?.close() } finally { + try { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + } + finally { await rm(root, { recursive: true, force: true }) } + } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no port') + const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } + clearAmbientProxyEnv(env) + const seen: SdkUserQuestionRequest[] = [] + const entered = Promise.withResolvers() + const harness = state.harness = new DeepSeekHarness({ + cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000, + onUserQuestion: async (request, { signal }) => { + expect(signal.aborted).toBe(false) + seen.push(request) + entered.resolve(signal) + if (cancel) return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('question cancelled')) }, { once: true }) + }) + return { answers: [{ id: 'format', selected: ['CSV'] }] } + }, + }) + const pending = harness.session('question-session').run('Ask which output format to use') + if (cancel) { + const settled = pending.catch(() => undefined) + const signal = await entered.promise + await harness.close() + await settled + expect(signal.aborted).toBe(true) + expect(requests).toHaveLength(1) + return + } + const result = await pending + expect(seen).toEqual([{ requestId: expect.any(String) as string, sessionId: 'question-session', questions }]) + expect(result.finalResponse).toBe('answer received') + expect(requests).toHaveLength(2) + expect(requests[1]?.max_tokens).toBe(1234) + expect(JSON.stringify(requests[1]?.messages)).toContain('CSV') + expect(result.events.some(event => event.type === 'tool/result' && event.data.error !== undefined)).toBe(false) +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6999b99e4d..a0a953d0dc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1435,12 +1435,12 @@ export interface Config { ## `@deepseek-ai/dsh-harness-dev-context` - `inject`: `systemPrompt` · `sessionProjections` -- `source`: [`packages/context/harness-dev-context/src/index.ts:29`](../packages/context/harness-dev-context/src/index.ts) +- `source`: [`packages/context/harness-dev-context/src/index.ts:31`](../packages/context/harness-dev-context/src/index.ts) ```ts config-catalog /** Desktop context supplied by a host or an SDK embedding. */ export interface Config { - /** Implementation checkout; omit for this installation, or false when unavailable. */ + /** Implementation checkout; omission detects a source tree, false disables detection. */ checkout?: string | false /** GUI URL; omit to use the mounted Web application when available. */ guiUrl?: string @@ -2721,8 +2721,8 @@ export interface Config { ## `@deepseek-ai/dsh-schedule` -- `inject`: `agents` · `sessions` · `storageDomain` · `sessionController` · `sessionPersistence` -- `source`: [`packages/schedule/schedule/src/index.ts:72`](../packages/schedule/schedule/src/index.ts) +- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence` +- `source`: [`packages/schedule/schedule/src/index.ts:82`](../packages/schedule/schedule/src/index.ts) ```ts config-catalog /** Configuration for the Host Schedule domain. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e66b7de5e3..293769b609 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1437,12 +1437,12 @@ export interface Config { ## `@deepseek-ai/dsh-harness-dev-context` - `inject`: `systemPrompt` · `sessionProjections` -- `source`: [`packages/context/harness-dev-context/src/index.ts:29`](../packages/context/harness-dev-context/src/index.ts) +- `source`: [`packages/context/harness-dev-context/src/index.ts:31`](../packages/context/harness-dev-context/src/index.ts) ```ts config-catalog /** Desktop context supplied by a host or an SDK embedding. */ export interface Config { - /** Implementation checkout; omit for this installation, or false when unavailable. */ + /** Implementation checkout; omission detects a source tree, false disables detection. */ checkout?: string | false /** GUI URL; omit to use the mounted Web application when available. */ guiUrl?: string @@ -2723,8 +2723,8 @@ export interface Config { ## `@deepseek-ai/dsh-schedule` -- `inject`: `agents` · `sessions` · `storageDomain` · `sessionController` · `sessionPersistence` -- `source`: [`packages/schedule/schedule/src/index.ts:72`](../packages/schedule/schedule/src/index.ts) +- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence` +- `source`: [`packages/schedule/schedule/src/index.ts:82`](../packages/schedule/schedule/src/index.ts) ```ts config-catalog /** Configuration for the Host Schedule domain. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 659b9d53a5..74f1badfaa 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -61,6 +61,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | +| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:73`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -80,7 +81,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | -| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 19d1da2409..300c4bd2ea 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -63,6 +63,7 @@ | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:282`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | | `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | +| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:73`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -82,7 +83,7 @@ | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | -| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e60b9c2c40..d54b53c75e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1350,6 +1350,7 @@ flowchart TD pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent + pkg_sdk_protocol --> pkg_user_questions pkg_workflow_ptc --> pkg_agent pkg_workflow_ptc --> pkg_llm pkg_workflow_ptc --> pkg_ptc_runtime @@ -1380,15 +1381,6 @@ flowchart TD pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_agent - pkg_sdk_jsonrpc_server --> pkg_attachment - pkg_sdk_jsonrpc_server --> pkg_llm - pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key - pkg_sdk_jsonrpc_server --> pkg_scope - pkg_sdk_jsonrpc_server --> pkg_sdk_protocol - pkg_sdk_jsonrpc_server --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_subagent - pkg_sdk_jsonrpc_server --> pkg_working_directory pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller @@ -1418,6 +1410,17 @@ flowchart TD pkg_tool_schedule --> pkg_schedule pkg_tool_schedule --> pkg_subagent pkg_tool_schedule --> pkg_tools + pkg_sdk_jsonrpc_server --> pkg_agent + pkg_sdk_jsonrpc_server --> pkg_attachment + pkg_sdk_jsonrpc_server --> pkg_llm + pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key + pkg_sdk_jsonrpc_server --> pkg_schedule + pkg_sdk_jsonrpc_server --> pkg_scope + pkg_sdk_jsonrpc_server --> pkg_sdk_protocol + pkg_sdk_jsonrpc_server --> pkg_session + pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_sdk_jsonrpc_server --> pkg_user_questions + pkg_sdk_jsonrpc_server --> pkg_working_directory ``` @@ -1754,13 +1757,13 @@ flowchart TD | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c7d0dfbe63..9be47acd83 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1352,6 +1352,7 @@ flowchart TD pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent + pkg_sdk_protocol --> pkg_user_questions pkg_workflow_ptc --> pkg_agent pkg_workflow_ptc --> pkg_llm pkg_workflow_ptc --> pkg_ptc_runtime @@ -1382,15 +1383,6 @@ flowchart TD pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_agent - pkg_sdk_jsonrpc_server --> pkg_attachment - pkg_sdk_jsonrpc_server --> pkg_llm - pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key - pkg_sdk_jsonrpc_server --> pkg_scope - pkg_sdk_jsonrpc_server --> pkg_sdk_protocol - pkg_sdk_jsonrpc_server --> pkg_session - pkg_sdk_jsonrpc_server --> pkg_subagent - pkg_sdk_jsonrpc_server --> pkg_working_directory pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller @@ -1420,6 +1412,17 @@ flowchart TD pkg_tool_schedule --> pkg_schedule pkg_tool_schedule --> pkg_subagent pkg_tool_schedule --> pkg_tools + pkg_sdk_jsonrpc_server --> pkg_agent + pkg_sdk_jsonrpc_server --> pkg_attachment + pkg_sdk_jsonrpc_server --> pkg_llm + pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key + pkg_sdk_jsonrpc_server --> pkg_schedule + pkg_sdk_jsonrpc_server --> pkg_scope + pkg_sdk_jsonrpc_server --> pkg_sdk_protocol + pkg_sdk_jsonrpc_server --> pkg_session + pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_sdk_jsonrpc_server --> pkg_user_questions + pkg_sdk_jsonrpc_server --> pkg_working_directory ``` @@ -1756,13 +1759,13 @@ flowchart TD | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | -| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 1c240a9766..a6e7893cf3 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -36,5 +36,5 @@ en: 077aa6573d9ede1a zh: 0acea592a6e3630a /host-wide-schedule/host-delivery: - en: b6d522d24b9b97ba - zh: 84df8ea09bedf51c + en: d5fc5b853529b139 + zh: 4cc5eba14f9660e6 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index c1d842141b..ef24673f9b 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -431,7 +431,7 @@ New tasks initialize an empty history. Older task rows without history expose on ## Host delivery -The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session through the Session controller, including cold Sessions. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch. +The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session, including cold Sessions, through `schedule/resolve-agent`. The embedding owns activation: the SDK handler waits for initialization and retains its Session handle and model options; without a handler, the Web Session controller restores the Session. The composition waits for its activation provider before starting Schedule. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch. Plugin-sourced `followup()` synchronously appends the message to the Session inbox. After a successful Session flush, one task-record put appends a saved delivery with its instruction snapshot and stores the latest `ScheduleDeliveryReceipt` together with the one-shot's `inactive` status or the recurring task's next target and status. Recurring tasks in the same batch share the receipt's `messageId`; each receipt retains that task's delivered occurrence time. Delivery never steers or cancels the current turn and does not wait for model completion. @@ -547,4 +547,24 @@ Durable task set changed; clients refetch global task and Session-active catalog ``` Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) + + + +#### `schedule/resolve-agent` — waterfall + +Restore a reminder's Session through its embedding's owning controller. Call next() to use the Web Session controller. + +```ts cordis-catalog +/** + * Restore a reminder's Session through its embedding's owning controller. + * Call next() to use the Web Session controller. + * @param sessionId - Original Session receiving the reminder. + * @mode waterfall + */ +'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise): Promise +``` + +Types: [Agent](core.md) · [SessionId](core.md) + +Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts) diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 0203ad8a85..2d140c3821 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -431,7 +431,7 @@ type ScheduleDeliveryHistoryResult = ## 宿主投递 -宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递时通过 Session controller 获取原 Session,包括尚未加载的 Session。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。 +宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递通过 `schedule/resolve-agent` 解析原 Session,包括尚未激活的会话。嵌入方拥有激活过程:SDK 处理方等待初始化,并保留其 Session handle 和模型选项;没有处理方时由 Web Session controller 恢复会话。组合等待其激活提供方就绪后再启动 Schedule。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。 插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后,一次任务记录 put 追加带有指令快照的发送记录,并同时存储最新的 `ScheduleDeliveryReceipt`,以及一次性任务的 `inactive` 状态或重复任务的下一目标和状态。同一批次的重复任务共享回执的 `messageId`;每条回执保留该任务已投递的发生时点。投递不会中途引导或取消当前轮次,也不等待模型完成。 @@ -547,4 +547,24 @@ Durable task set changed; clients refetch global task and Session-active catalog ``` Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) + + + +#### `schedule/resolve-agent` — waterfall + +Restore a reminder's Session through its embedding's owning controller. Call next() to use the Web Session controller. + +```ts cordis-catalog +/** + * Restore a reminder's Session through its embedding's owning controller. + * Call next() to use the Web Session controller. + * @param sessionId - Original Session receiving the reminder. + * @mode waterfall + */ +'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise): Promise +``` + +Types: [Agent](core.zh.md) · [SessionId](core.zh.md) + +Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts) diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.i18n.yaml index fa04518cb6..967de7c8e0 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.i18n.yaml @@ -12,5 +12,5 @@ en: cce3baa734a032c5 zh: 5569352cdc7c3ea6 /sdk-desktop-tools-and-context/migration: - en: 2a274b8e605e1cc8 - zh: 0117f4bd6d048275 + en: aa889cac30c2eac6 + zh: 028d071aaa6925ed diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.md b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.md index a0dbd63b2c..747297ceb9 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.md @@ -13,6 +13,6 @@ The existing `sdk` profile now includes `present`, `ask_user_question`, schedule ## Migration -1. Keep selecting `sdk`. Supply an answerer for `user-questions/request` if your application exposes questions; without one, the tool reports `NO_PROVIDER`. -2. Set `checkout` and `guiUrl` on `harness-dev-context` only when your embedding provides them. The defaults report them as unavailable. Working directory and model still come from SDK initialization. +1. Keep selecting `sdk`. Provide `onUserQuestion` (TypeScript) or `on_user_question` (Python) to answer built-in questions over the native protocol, or retain an in-process `user-questions/request` answerer; without one, the tool reports `NO_PROVIDER`. +2. Set `checkout` and `guiUrl` on `harness-dev-context` only when your embedding provides them. The checkout is detected only when the installation contains the CLI and Agent source trees; a GUI URL remains unavailable unless provided. Working directory and model still come from SDK initialization. 3. Custom compositions that need the previous model-visible tool/context roster can disable the `present`, `tool-ask-user`, `tool-schedule`, `file-reference-local`, `deliverable-file-references`, `harness-dev-context`, and `time-context` rows. Review any pinned prompts and tool schemas before upgrading. diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.zh.md index 59fbc9d063..aaca9199ef 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.4/sdk-desktop-context/guide.zh.md @@ -13,6 +13,6 @@ description: 现有 SDK profile 包含原生桌面工具和模型上下文。 ## 迁移 -1. 继续选择 `sdk`。如果应用支持提问,请为 `user-questions/request` 提供回答方;没有回答方时,工具报告 `NO_PROVIDER`。 -2. 仅在嵌入方提供对应值时,在 `harness-dev-context` 上设置 `checkout` 和 `guiUrl`。默认报告两者不可用。工作目录和模型仍来自 SDK 初始化。 +1. 继续选择 `sdk`。通过 `onUserQuestion`(TypeScript)或 `on_user_question`(Python)使用原生协议回答内建问题,或保留进程内的 `user-questions/request` 回答方;没有回答方时,工具报告 `NO_PROVIDER`。 +2. 仅在嵌入方提供对应值时,在 `harness-dev-context` 上设置 `checkout` 和 `guiUrl`。仅在安装中包含 CLI 和 Agent 源码树时自动识别 checkout;未提供 GUI URL 时报告其不可用。工作目录和模型仍来自 SDK 初始化。 3. 需要保留之前模型可见工具/上下文组合的自定义配置,可以禁用 `present`、`tool-ask-user`、`tool-schedule`、`file-reference-local`、`deliverable-file-references`、`harness-dev-context` 和 `time-context` 配置项。升级前请检查固定的提示词和工具 schema。 diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index c7289ecc5b..2bb2fba984 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -24,8 +24,8 @@ en: 4f88e647c6df4f42 zh: 2bd3e4b0bdfe9412 /deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/what-the-model-sees: - en: 6010fddb90533167 - zh: 3c23a079483fe2d4 + en: 1cc1ab77e1ecb0a2 + zh: b9c1acb461a6afcc /deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/token-effect: en: 0d4a38b2448396b3 zh: e63408a146bf0100 @@ -33,8 +33,8 @@ en: 0503df0971e5f8d7 zh: 18cae8c6e25cda04 /deepseek-ai-dsh-sdk-app/known-limitations-and-deferred-work: - en: fb5e517c2d863b5a - zh: 6340e3ebe1601f2b + en: e72eec2652f06741 + zh: 520fe70fffeaabe8 /deepseek-ai-dsh-sdk-app/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index e3fdfdffb7..245c5ae015 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -46,7 +46,7 @@ The Python runtime exposes explicitly downloaded authoring resources and Office #### What the model sees -The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, and adds the same model-interpolated coding persona as the Web and headless profiles. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`. The model also receives `present`, `ask_user_question`, schedule tools, shared file-reference guidance, and time context. Child agents cannot use schedule tools. The [`harness-dev-context`](../../context/harness-dev-context/README.md) row accepts `checkout` and `guiUrl`; by default both are unavailable. The [`time-context`](../../context/time-context/README.md) row accepts a fallback time zone. Skills use the configured resources described above. +The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, and adds the same model-interpolated coding persona as the Web and headless profiles. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`. The model also receives `present`, `ask_user_question`, schedule tools, shared file-reference guidance, and time context. Child agents cannot use schedule tools. The [`harness-dev-context`](../../context/harness-dev-context/README.md) row accepts `checkout` and `guiUrl`; it detects a source checkout when the installation contains the CLI and Agent source trees, while the GUI remains unavailable without an embedding-provided URL. The [`time-context`](../../context/time-context/README.md) row accepts a fallback time zone. Skills use the configured resources described above. #### Token effect @@ -61,7 +61,7 @@ The static prefix is stable for a fixed profile, provider, model, and tool roste - **No graphical client** — file cards, native file opening, and browser state require an embedding client; the SDK starts no Web server. -- **Questions require an answerer** — the embedding must provide the native `user-questions/request` handler; otherwise `ask_user_question` returns `NO_PROVIDER`. +- **Questions require an answerer** — configure `onUserQuestion` in TypeScript or `on_user_question` in Python, or supply a native `user-questions/request` handler; otherwise `ask_user_question` returns `NO_PROVIDER`. - **Schedules share the runtime lifetime** — delivery needs a running process and its persistent Session storage. - **A profile can omit the SDK server** — a custom profile selected by the TypeScript client must retain this bundle or another `dsh-sdk-jsonrpc-server` row; client initialization fails when no peer answers. - **User plugins can violate stdout purity** — profile and per-launch patches are trusted application composition. The shipped bundle writes no non-protocol stdout, but it cannot contain an arbitrary inserted plugin. diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index d8f50b575d..cb2ddc0aa8 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -46,7 +46,7 @@ Python 运行时向本 profile 提供显式下载的创作资源和 Office sidec #### 模型看到什么 -profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,并添加与 Web 和 headless profile 相同、按当前模型插值的 coding persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。模型还会收到 `present`、`ask_user_question`、定时任务工具、共用文件引用说明和时间上下文。子 agent 不能使用定时任务工具。[`harness-dev-context`](../../context/harness-dev-context/README.zh.md) 配置项接受 `checkout` 和 `guiUrl`;默认两者均不可用。[`time-context`](../../context/time-context/README.zh.md) 配置项接受回退时区。Skills 使用上述配置的资源。 +profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,并添加与 Web 和 headless profile 相同、按当前模型插值的 coding persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。模型还会收到 `present`、`ask_user_question`、定时任务工具、共用文件引用说明和时间上下文。子 agent 不能使用定时任务工具。[`harness-dev-context`](../../context/harness-dev-context/README.zh.md) 配置项接受 `checkout` 和 `guiUrl`;安装中包含 CLI 和 Agent 源码树时自动识别 checkout;没有嵌入方提供的 URL 时 GUI 不可用。[`time-context`](../../context/time-context/README.zh.md) 配置项接受回退时区。Skills 使用上述配置的资源。 #### Token 影响 @@ -61,7 +61,7 @@ profile 继承 base 系统提示词,包括 `You are an AI agent powered by Dee - **没有图形客户端**:文件卡片、本地文件打开和浏览器状态需要嵌入客户端;SDK 不启动 Web server。 -- **提问需要回答方**:嵌入方必须提供原生 `user-questions/request` handler,否则 `ask_user_question` 返回 `NO_PROVIDER`。 +- **提问需要回答方**:在 TypeScript 中配置 `onUserQuestion`、在 Python 中配置 `on_user_question`,或提供原生 `user-questions/request` handler;否则 `ask_user_question` 返回 `NO_PROVIDER`。 - **定时任务依赖运行时生命周期**:投递需要运行中的进程及其持久化 Session 存储。 - **profile 可能省略 SDK server**:TypeScript client 选择的自定义 profile 必须保留本组合包或另一个 `dsh-sdk-jsonrpc-server` 配置项;没有 peer 响应时,client 初始化会失败。 - **用户插件可以破坏 stdout 纯净性**:profile 与逐次启动 patch 属于受信任应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入插件。 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 1d709c1c82..2e6392ad01 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -55,11 +55,9 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' - - id: session-controller - name: '@deepseek-ai/dsh-api-session-controller' - - id: schedule name: '@deepseek-ai/dsh-schedule' + inject: [sdkSessionActivation] - id: time-context name: '@deepseek-ai/dsh-time-context' @@ -81,5 +79,3 @@ - id: harness-dev-context name: '@deepseek-ai/dsh-harness-dev-context' - config: - checkout: false diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 7b7162e9cd..39d2b37307 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -43,7 +43,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-file-upload": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", - "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-tool-schedule": "workspace:*", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index a60884045b..e98a640867 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -138,6 +138,7 @@ # which declare them in their own scope. - id: schedule name: '@deepseek-ai/dsh-schedule' + inject: [sessionController] # Configuration-surface reads and writes over Typert Remote. Each method # reports an actionable error when its settings-domain provider is absent. diff --git a/packages/context/harness-dev-context/README.i18n.yaml b/packages/context/harness-dev-context/README.i18n.yaml index 71f74fe4d0..bdaf03bc56 100644 --- a/packages/context/harness-dev-context/README.i18n.yaml +++ b/packages/context/harness-dev-context/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-harness-dev-context/use-this-package: - en: 790f639922ed02a5 - zh: f4a0355307e2521a + en: 3f8417be2523dc7d + zh: c26f64067e3947bd /deepseek-ai-dsh-harness-dev-context/use-this-package/what-the-model-gets: - en: d9f5b11af33b3eb8 - zh: fc96b972503a0b99 + en: 61ed8e1b10c64299 + zh: 4f465b5aeeb64a08 /deepseek-ai-dsh-harness-dev-context/use-this-package/sessions-from-earlier-releases: en: d810950db5118265 zh: d20e3965fa78e0f8 diff --git a/packages/context/harness-dev-context/README.md b/packages/context/harness-dev-context/README.md index 334f4669f6..dc4e7c7e96 100644 --- a/packages/context/harness-dev-context/README.md +++ b/packages/context/harness-dev-context/README.md @@ -25,11 +25,11 @@ English | [中文](README.zh.md) ## Use this package -The Web and `sdk` bundles mount the `harness-dev-context` row. It requires `systemPrompt` and `sessionProjections`; `webApp` is optional. Set `checkout` to an implementation path, omit it to use this installation, or set it to `false` when no checkout is provided. Set `guiUrl` to the embedding GUI URL; otherwise the plugin reads `webApp.getAdvertisedUrl()` when available. Missing values are reported as `not provided for this SDK session`. Disable the row to omit both sections and contexts. +The Web and `sdk` bundles mount the `harness-dev-context` row. It requires `systemPrompt` and `sessionProjections`; `webApp` is optional. Set `checkout` to a host-provided implementation source path, omit it to detect whether this installation contains the CLI and Agent source trees, or set it to `false` to disable detection. Detection runs once at activation; a packaged runtime without these sources is not a checkout. Set `guiUrl` to the embedding GUI URL; otherwise the plugin reads `webApp.getAdvertisedUrl()` when available. Missing values are reported as `not available`. GUI and browser HMR guidance is conditional; a headless runtime does not claim that a browser or client HMR receiver is active. Server plugin/configuration HMR, the browser receiver, and the build watcher are independent capabilities. Disable the row to omit both sections and contexts. ### What the model gets -Two system-prompt sections explain that the checkout differs from the working directory and that "this page" means this GUI, together with the GUI update contract. Two runtime-context lines state the current values: the installation root four directories above this package, and the Web GUI's advertised URL from `webApp.getAdvertisedUrl()`, the same value as `DSH_WEB_URL` and the startup URL. Runtime context follows the shared snapshot rules: an unchanged value adds nothing, and a changed one appends a snapshot before the step's input instead of rewriting earlier messages. +Two system-prompt sections explain that the checkout differs from the working directory and that "this page" means this GUI, together with the GUI update contract. Two runtime-context lines state the current values: the detected or host-provided implementation checkout when available, and the Web GUI's advertised URL from `webApp.getAdvertisedUrl()`, the same value as `DSH_WEB_URL` and the startup URL. Runtime context follows the shared snapshot rules: an unchanged value adds nothing, and a changed one appends a snapshot before the step's input instead of rewriting earlier messages. ### Sessions from earlier releases @@ -87,9 +87,9 @@ Two system-prompt paragraphs at first-party orders 10000 and 10100, after reusab ##### System-prompt paragraphs ```markdown -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. ``` ##### Runtime-context lines diff --git a/packages/context/harness-dev-context/README.zh.md b/packages/context/harness-dev-context/README.zh.md index ace202c820..24b9ccba7a 100644 --- a/packages/context/harness-dev-context/README.zh.md +++ b/packages/context/harness-dev-context/README.zh.md @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -Web 和 `sdk` bundle 挂载 `harness-dev-context` 行。它依赖 `systemPrompt` 和 `sessionProjections`,`webApp` 为可选服务。将 `checkout` 设为实现路径;省略时使用当前安装位置,未提供 checkout 时设为 `false`。将 `guiUrl` 设为嵌入应用的 GUI URL;否则插件在服务可用时读取 `webApp.getAdvertisedUrl()`。缺失值显示为 `not provided for this SDK session`。禁用该行会同时省略两个提示段和上下文。 +Web 和 `sdk` bundle 挂载 `harness-dev-context` 行。它依赖 `systemPrompt` 和 `sessionProjections`,`webApp` 为可选服务。将 `checkout` 设为宿主提供的实现源码路径;省略时检测安装中是否包含 CLI 和 Agent 源码树,设为 `false` 则关闭检测。检测仅在激活时执行一次;不含这些源码的打包运行时不会被当作 checkout。将 `guiUrl` 设为嵌入应用的 GUI URL;否则插件在服务可用时读取 `webApp.getAdvertisedUrl()`。缺失值显示为 `not available`。GUI 和浏览器 HMR 说明以相应能力可用为前提;headless 运行时不会声称浏览器或客户端 HMR 接收器已启用。服务端插件/配置 HMR、浏览器接收器和构建 watcher 是独立的能力。禁用该行会同时省略两个提示段和上下文。 ### 模型得到什么 -两个系统提示词段落说明 checkout 与工作目录不同、「this page」指此 GUI,并给出 GUI 的更新约定。两行运行时上下文给出当前值:本包上方四层目录处的安装根,以及 `webApp.getAdvertisedUrl()` 给出的 Web GUI 公告 URL,与 `DSH_WEB_URL` 和启动 URL 相同。运行时上下文遵循共享的快照规则:值不变不追加内容,值变化时在本步输入之前追加一份快照,而不是改写更早的消息。 +两个系统提示词段落说明 checkout 与工作目录不同、「this page」指此 GUI,并给出 GUI 的更新约定。两行运行时上下文给出当前值:检测到或宿主提供的实现 checkout(若可用),以及 `webApp.getAdvertisedUrl()` 给出的 Web GUI 公告 URL,与 `DSH_WEB_URL` 和启动 URL 相同。运行时上下文遵循共享的快照规则:值不变不追加内容,值变化时在本步输入之前追加一份快照,而不是改写更早的消息。 ### 早期版本的会话 @@ -87,9 +87,9 @@ Web 和 `sdk` bundle 挂载 `harness-dev-context` 行。它依赖 `systemPrompt` ##### 系统提示词段落 ```markdown -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. ``` ##### 运行时上下文行 diff --git a/packages/context/harness-dev-context/src/index.ts b/packages/context/harness-dev-context/src/index.ts index d1443640d3..c2881afb41 100644 --- a/packages/context/harness-dev-context/src/index.ts +++ b/packages/context/harness-dev-context/src/index.ts @@ -10,6 +10,8 @@ */ import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { z as zod } from 'zod' import z from '@deepseek-ai/schemastery' @@ -27,7 +29,7 @@ export const inject = ['systemPrompt', 'sessionProjections'] /** Desktop context supplied by a host or an SDK embedding. */ export interface Config { - /** Implementation checkout; omit for this installation, or false when unavailable. */ + /** Implementation checkout; omission detects a source tree, false disables detection. */ checkout?: string | false /** GUI URL; omit to use the mounted Web application when available. */ guiUrl?: string @@ -52,18 +54,19 @@ const HARNESS_SOURCE_GUIDANCE = 'The checkout location and current working direc + 'never infer the working directory from the checkout path. Use pwd to determine the current working directory. ' + 'Use this checkout only to inspect or extend DSH itself.' -const HARNESS_SOURCE_PROMPT = `The runtime context names the DeepSeek Harness implementation checkout. ${HARNESS_SOURCE_GUIDANCE}` +const HARNESS_SOURCE_PROMPT = `The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. ${HARNESS_SOURCE_GUIDANCE}` -const WEB_SURFACE_GUIDANCE = 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' +const WEB_SURFACE_GUIDANCE = 'When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. ' + 'The browser provides no implicit DOM, route, or screenshot context. ' - + 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while ' - + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. ' + + 'Client-plugin changes reload without a refresh only when the browser HMR receiver is active and ' + + '`pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. ' + + 'Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. ' + 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. ' + 'Starting another server does not update this GUI. ' + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.' -const WEB_SURFACE_PROMPT = `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. ${WEB_SURFACE_GUIDANCE}` +const WEB_SURFACE_PROMPT = `The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. ${WEB_SURFACE_GUIDANCE}` /** * Fixed text around the value in each paragraph that embedded it, frozen as @@ -142,6 +145,9 @@ const ADMITS_NOTHING: LegacyPrompt = { source: null, web: null } * @param config - optional embedding checkout and GUI URL. */ export function apply(ctx: Context, config: Config = {}): void { + // Inspect only at activation. A packaged runtime is not necessarily a source checkout. + const checkout = config.checkout ?? (existsSync(join(SOURCE_ROOT, 'apps/cli/src/bin.ts')) + && existsSync(join(SOURCE_ROOT, 'packages/core/agent/src/index.ts')) ? SOURCE_ROOT : false) ctx.sessionProjections.register({ key: LEGACY_PROMPT, stateVersion: 1, @@ -164,8 +170,8 @@ export function apply(ctx: Context, config: Config = {}): void { name: HARNESS_SOURCE, order: ctx.systemPrompt.getContextOrder('HARNESS_SOURCE'), interpolate: false, - text: `Current DeepSeek Harness implementation checkout: ${config.checkout === false - ? 'not provided for this SDK session' : JSON.stringify(config.checkout ?? SOURCE_ROOT)}.`, + text: `Current DeepSeek Harness implementation checkout: ${checkout === false + ? 'not available' : JSON.stringify(checkout)}.`, }) ctx.systemPrompt.section({ name: WEB_SURFACE, @@ -180,7 +186,7 @@ export function apply(ctx: Context, config: Config = {}): void { text: () => { const url = config.guiUrl ?? ctx.get('webApp')?.getAdvertisedUrl() return `Current DeepSeek Harness Web GUI URL: ${url === undefined - ? 'not provided for this SDK session' : JSON.stringify(url)}.` + ? 'not available' : JSON.stringify(url)}.` }, }) } diff --git a/packages/context/harness-dev-context/tests/harness-dev-context.spec.ts b/packages/context/harness-dev-context/tests/harness-dev-context.spec.ts index 3c7313e561..22c15ddd40 100644 --- a/packages/context/harness-dev-context/tests/harness-dev-context.spec.ts +++ b/packages/context/harness-dev-context/tests/harness-dev-context.spec.ts @@ -1,6 +1,7 @@ /** Prompt stability of the harness checkout and Web GUI URL across processes, checkouts, and ports. */ -import { describe, expect, it, onTestFinished } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { existsSync } from 'node:fs' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -12,16 +13,19 @@ import { SessionId, type Session } from '@deepseek-ai/dsh-session' import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import * as HarnessDevContext from '../src/index.ts' -const SOURCE_PROMPT = 'The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.' -const WEB_GUIDANCE = 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' +vi.mock('node:fs', { spy: true }) + +const SOURCE_PROMPT = 'The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.' +const WEB_GUIDANCE = 'When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. ' + 'The browser provides no implicit DOM, route, or screenshot context. ' - + 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while ' - + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. ' + + 'Client-plugin changes reload without a refresh only when the browser HMR receiver is active and ' + + '`pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. ' + + 'Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. ' + 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. ' + 'Starting another server does not update this GUI. ' + 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. ' + 'Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.' -const WEB_PROMPT = `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. ${WEB_GUIDANCE}` +const WEB_PROMPT = `The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. ${WEB_GUIDANCE}` /** The value-bearing paragraphs earlier releases rendered. */ const LEGACY_SOURCE = 'The DeepSeek Harness implementation checkout is at /old/checkout/. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.' @@ -130,6 +134,22 @@ function requestSystemPrompts(request: GenerateOptions | undefined): string[] { } describe('harness-dev-context', () => { + it.each([0, 1])('reports no checkout when source marker %i is absent and probes only at activation', async (missingMarker) => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + const probe = vi.mocked(existsSync) + onTestFinished(() => { probe.mockRestore() }) + if (missingMarker === 1) probe.mockReturnValueOnce(true) + probe.mockReturnValueOnce(false) + await ctx.plugin(HarnessDevContext) + const probes = probe.mock.calls.length + expect(renderContextSnapshot(await ctx.systemPrompt.assemble())) + .toContain('Current DeepSeek Harness implementation checkout: not available.') + await ctx.systemPrompt.assemble() + expect(probe).toHaveBeenCalledTimes(probes) + }) + it('registers value-free sections and reports the checkout and Web GUI URL as runtime context', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -153,7 +173,7 @@ describe('harness-dev-context', () => { }) it.each([ - { config: { checkout: false as const }, expected: ['not provided for this SDK session', 'not provided for this SDK session'] }, + { config: { checkout: false as const }, expected: ['not available', 'not available'] }, { config: { checkout: '/embedding/source', guiUrl: 'http://127.0.0.1:4312' }, expected: ['"/embedding/source"', '"http://127.0.0.1:4312"'] }, ])('renders SDK desktop context without a Web server: $config', async ({ config, expected }) => { const ctx = new Context() diff --git a/packages/deliverables/tool-present/README.md b/packages/deliverables/tool-present/README.md index 178b21858d..ebd90742c7 100644 --- a/packages/deliverables/tool-present/README.md +++ b/packages/deliverables/tool-present/README.md @@ -94,7 +94,7 @@ The separate `./prompt` entry registers this static guidance for desktop file re ##### Shared guidance ```markdown -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. ``` #### Token effect diff --git a/packages/deliverables/tool-present/README.zh.md b/packages/deliverables/tool-present/README.zh.md index 84c18ffb1c..185e5bd3de 100644 --- a/packages/deliverables/tool-present/README.zh.md +++ b/packages/deliverables/tool-present/README.zh.md @@ -94,7 +94,7 @@ kind: "package-reference" ##### 共用指导 ```markdown -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. ``` #### Token 影响 diff --git a/packages/deliverables/tool-present/src/prompt.ts b/packages/deliverables/tool-present/src/prompt.ts index aaa71b304c..14dced3913 100644 --- a/packages/deliverables/tool-present/src/prompt.ts +++ b/packages/deliverables/tool-present/src/prompt.ts @@ -12,7 +12,7 @@ export const inject = ['systemPrompt'] const FILE_REFERENCE_PROMPT = 'Prefer showing the primary results within your final response alongside a brief explanation. ' + 'Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. ' + 'Do not call present just to list edited source files, or run commands to check whether a diff view will appear. ' - + 'Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. ' + + 'Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. ' + 'Avoid repeating results already shown inline unless the separate card adds useful access. ' + 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. ' + 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.' diff --git a/packages/deliverables/tool-present/tests/prompt.spec.ts b/packages/deliverables/tool-present/tests/prompt.spec.ts index 34af634f48..4d62c14685 100644 --- a/packages/deliverables/tool-present/tests/prompt.spec.ts +++ b/packages/deliverables/tool-present/tests/prompt.spec.ts @@ -22,7 +22,7 @@ describe('desktop file-reference prompt', () => { const section = (await ctx.systemPrompt.assemble()).sections .find(entry => entry.name === 'ui:deliverable-file-references') - expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') + expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') // Only live request pins track current guidance; seeded sessions retain their recorded prompt. for (const scenario of ['fresh-round-trip', 'ptc-round']) { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 97da088137..e426eef82c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4421,6 +4421,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Durable task set changed; clients refetch global task and Session-active catalogs.', parameters: [], }, + { + name: 'schedule/resolve-agent', + mode: 'waterfall', + signature: '\'schedule/resolve-agent\'(sessionId: SessionId, next: () => Promise): Promise', + summary: 'Restore a reminder\'s Session through its embedding\'s owning controller.', + description: 'Restore a reminder\'s Session through its embedding\'s owning controller. Call next() to use the Web Session controller.', + parameters: [{ name: 'sessionId', description: 'Original Session receiving the reminder.' }], + }, { name: 'session-telemetry/record', mode: 'waterfall', diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index f0c56100d6..9be69f28f3 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: 88aba0cd93ff0903 - zh: a8e4c257c319de53 + en: c240e96ca3374259 + zh: cb5299e7621ece4f /deepseek-ai-dsh-schedule/understand-the-implementation: en: 3d11c664125e32d8 zh: 01a007a5ffb2ab63 diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 19125f6137..8e18eb5d48 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -23,7 +23,7 @@ Schedule delivers one-shot, fixed-rate, daily, weekly, and cron wall-clock remin ## Use this package -The shipped Web composition mounts the service alongside storage-domain and the Session controller. Its `Config` states `deliveryHistoryDays` (default 30) and `deliveryHistoryRecords` (default 200). Storage backend routing belongs to storage-domain; Session model and preset restoration belong to the Session controller. Schedule cannot be mounted alone in a headless or SDK-only composition: delivery requires the Host Web Session controller and a Session persistence backend, because a delivery commits only after the Session acknowledges `session/flush`. +The shipped Web composition mounts the service alongside storage-domain and the Session controller. Its `Config` states `deliveryHistoryDays` (default 30) and `deliveryHistoryRecords` (default 200). Storage backend routing belongs to storage-domain; Session model and preset restoration belong to the Session controller. Delivery asks `schedule/resolve-agent` to activate the original Session through its owning controller; without a handler it uses the Web Session controller. The SDK server supplies its own handler and does not mount the Web controller. Compositions must wait for their activation provider before mounting Schedule. A Session persistence backend is required because a delivery commits only after the Session acknowledges `session/flush`. The reminder tools `schedule_create`, `schedule_list`, `schedule_delete`, and `schedule_update` come from [`@deepseek-ai/dsh-tool-schedule`](../tool-schedule/README.md), which a preset mounts; this package contributes the Host service those tools call. Update replaces the name, instruction, or timing of one reminder in place and keeps its id and saved records; it is not offered for the relative `after` delay. Creation requires a non-empty prompt, a title, and exactly one of six selectors: diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 39eb161d5c..e4b04fd860 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -23,7 +23,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 ## 使用此包 -随发行版交付的 Web 组合把此服务与 storage-domain、Session controller 一起挂载。其 `Config` 声明 `deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200)。存储后端路由由 storage-domain 管理;会话模型与 preset 恢复由 Session controller 管理。Schedule 无法在 headless 或仅 SDK 的组合中单独挂载:投递需要 Host 的 Web Session controller 和 Session 持久化后端,因为只有在 Session 确认 `session/flush` 之后一次投递才会提交。 +随发行版交付的 Web 组合把此服务与 storage-domain、Session controller 一起挂载。其 `Config` 声明 `deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200)。存储后端路由由 storage-domain 管理;会话模型与 preset 恢复由 Session controller 管理。投递通过 `schedule/resolve-agent` 请求会话所属的控制器激活原 Session;没有处理方时使用 Web Session controller。SDK server 提供自己的处理方,不挂载 Web 控制器。组合应等待其激活提供方就绪后再挂载 Schedule。投递仍需要 Session 持久化后端,因为只有在 Session 确认 `session/flush` 之后一次投递才会提交。 提醒工具 `schedule_create`、`schedule_list`、`schedule_delete` 和 `schedule_update` 来自 [`@deepseek-ai/dsh-tool-schedule`](../tool-schedule/README.zh.md),由 preset 挂载;本包提供这些工具调用的宿主服务。更新原地替换一条提醒的名称、指令或时间,保留其 id 与已保存记录;相对的 `after` 延迟不支持更新。创建时需要非空提示文本、标题,且必须只提供以下六个选择器之一: diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index b7e4ecb62a..fcedcc217a 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' import { Context, Service } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import type { Domain } from '@deepseek-ai/dsh-storage-domain' import { delegationDepthOf } from '@deepseek-ai/dsh-subagent' @@ -62,6 +63,15 @@ export { declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Restore a reminder's Session through its embedding's owning controller. + * Call next() to use the Web Session controller. + * @param sessionId - Original Session receiving the reminder. + * @mode waterfall + */ + 'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise): Promise + } interface Context { /** Durable Host-wide reminder management. */ schedule: ScheduleService @@ -97,7 +107,7 @@ const DEFAULT_DELIVERY_HISTORY_RECORDS = 200 * service is the plugin that registers that listener. */ export class ScheduleService extends TypertRemoteService { - static inject = ['agents', 'sessions', 'storageDomain', 'sessionController', 'sessionPersistence'] + static inject = ['agents', 'sessions', 'storageDomain', 'sessionPersistence'] static Config: z = z.object({ deliveryHistoryDays: z.number().step(1).min(1).max(3650).default(DEFAULT_DELIVERY_HISTORY_DAYS), diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index 0fc7f3dd0b..2e542b751f 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -98,8 +98,13 @@ export class ScheduleRuntime { let admitted = group const committed = new Set() try { - const resolved = await this.ctx.sessionController.resolveAgent(task.sessionId) - if ('error' in resolved) throw resolved.error + const agent = await this.ctx.waterfall('schedule/resolve-agent', task.sessionId, async () => { + const controller = this.ctx.get('sessionController') + if (controller === undefined) throw new Error('schedule: no Session activation provider') + const resolved = await controller.resolveAgent(task.sessionId) + if ('error' in resolved) throw resolved.error + return resolved.agent + }) // oxlint-disable-next-line typescript/no-unnecessary-condition -- Disposal can run while Session restoration is awaited. if (this.stopping) return const now = Date.now() @@ -120,8 +125,8 @@ export class ScheduleRuntime { content: [{ type: 'text', text }], source: { kind: 'schedule' }, }) // followup synchronously appends the inbox splice before flush observes the Session. - resolved.agent.followup(message) - const flushed = await this.ctx.sessions.flush(resolved.agent.session) + agent.followup(message) + const flushed = await this.ctx.sessions.flush(agent.session) if (!flushed) throw new Error('Session persistence did not acknowledge the reminder') const deliveredAt = new Date(Date.now()).toISOString() if (!isRecurringScheduleRecord(task.record)) { diff --git a/packages/schedule/schedule/tests/harness.ts b/packages/schedule/schedule/tests/harness.ts index bf152ad187..b8c618a54d 100644 --- a/packages/schedule/schedule/tests/harness.ts +++ b/packages/schedule/schedule/tests/harness.ts @@ -67,6 +67,7 @@ export async function harness(options: { pool?: MemoryMediaPool backend?: StorageBackend direct?: true + sessionController?: false config?: Config } = {}) { const ctx = new Context() @@ -87,7 +88,7 @@ export async function harness(options: { return async () => { await facility.closeAll(); unmount() } }) const resolve = vi.fn(async () => { throw new Error('missing Session') }) - ctx.provide('sessionController', { resolveAgent: resolve } as never) + if (options.sessionController !== false) ctx.provide('sessionController', { resolveAgent: resolve } as never) const flush = vi.fn(async () => {}) ctx.on('session/flush', flush) // This fixture supplies flush behavior above; Schedule does not invoke persistence methods directly. diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index 9feda76ac7..e73f9b4f5e 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -18,8 +18,8 @@ afterEach(async () => { await Promise.all(tests.splice(0).map(test => test.ctx.fiber.dispose())) vi.useRealTimers() }) -async function setup(tasks: ScheduleTask[]) { - const test = await harness(); tests.push(test) +async function setup(tasks: ScheduleTask[], options: Parameters[0] = {}) { + const test = await harness(options); tests.push(test) const agent = agentFor(test.ctx) const followup = vi.fn() agent.followup = followup @@ -59,6 +59,33 @@ it('seeds legacy history only from its actual receipt and appends through wall-c }) describe('Host Schedule timer', () => { + it('keeps reminders unacknowledged when no Session activation provider is mounted', async () => { + const tasks = [task(createAfterScheduleRecord(ScheduleId('unavailable'), 'Remember', 2, Date.now(), 'Remember'))] + const test = await setup(tasks, { sessionController: false }) + const warn = vi.spyOn(test.ctx.logger, 'warn').mockImplementation(() => {}) + try { + test.runtime.requestDrive() + await vi.advanceTimersByTimeAsync(2000) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no Session activation provider')) + expect(test.followup).not.toHaveBeenCalled() + expect(test.commit).not.toHaveBeenCalled() + expect(tasks[0]?.status).toBe('active') + } finally { warn.mockRestore() } + }) + + it('uses the embedding activation handler instead of the Web controller', async () => { + const tasks = [task(createAfterScheduleRecord(ScheduleId('embedded'), 'Remember', 2, Date.now(), 'Remember'))] + const test = await setup(tasks) + const activate = vi.fn(async () => test.agent) + test.ctx.on('schedule/resolve-agent', activate) + test.runtime.requestDrive() + await vi.advanceTimersByTimeAsync(2000) + expect(activate).toHaveBeenCalledWith(SessionId('original'), expect.any(Function)) + expect(test.resolve).not.toHaveBeenCalled() + expect(test.followup).toHaveBeenCalledOnce() + expect(tasks[0]?.status).toBe('inactive') + }) + it('restores the original Session and retains an ended one-shot with its flushed message receipt', async () => { const tasks = [task(createAfterScheduleRecord(ScheduleId('once'), 'Remember', 2, Date.now(), 'Remember'))] const test = await setup(tasks) diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index db75bc1878..fa616c99f1 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -21,8 +21,11 @@ en: 01c58dddd7e62de2 zh: 6dbd46e244f3f135 /deepseek-ai-dsh-sdk-client/use-this-package/lower-level-control-with-harnessclient: - en: 8778901987da6e59 - zh: 3d09fe4b0b393a15 + en: 2eb13706964444cb + zh: a2640ec27b83113f +/deepseek-ai-dsh-sdk-client/use-this-package/native-user-questions: + en: 68e4bd95f31c77bb + zh: 74ceff119831784a /deepseek-ai-dsh-sdk-client/understand-the-implementation: en: 8773f50c51da85f0 zh: 4649ebc69357deb0 @@ -48,8 +51,8 @@ en: 7abe0be104a791e7 zh: 445311f78b1069ca /deepseek-ai-dsh-sdk-client/known-limitations-and-deferred-work: - en: 3f51394ef0699943 - zh: 6d4c3180fced8db1 + en: 60100c38c111a0b0 + zh: 2085486bd36f5e48 /deepseek-ai-dsh-sdk-client/known-limitations-and-deferred-work/dev-note: en: 6177a56f053936a4 zh: 0f4b20228c2d15da diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 2b64ba7417..21d1368f5a 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -59,6 +59,13 @@ Task-owning consumers can subscribe before `prompt()`, then call `request('sessi The client exports typed errors for every failure mode: `JsonRpcResponseError` (a wire error response, code and data preserved), `RequestTimeoutError` (a configured bound elapsed), `SdkProtocolError` (a response outside the documented protocol), and `TransportClosedError` (the runtime is gone — the message carries the exit code and a bounded stderr tail). `close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder until the process has exited; it is idempotent, and a closed client refuses reuse. `HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `scrubbedParentEnv` from `dsh-subprocess` is the shared scrub base for isolation-minded launches. + +### Native user questions + +Pass `onUserQuestion` to `DeepSeekHarness` or `HarnessClient` to answer the built-in `ask_user_question` tool through the native SDK protocol. The callback receives `{ requestId, sessionId, questions }` and `{ signal }`; return `{ answers: [{ id, selected, custom? }] }`, using one item per question id. Your application owns the UI or simulated-user policy. The SDK automatically advertises the callback at initialization and can receive answers while other requests are pending. + +Observe the signal to release host work when the runtime cancels the question, disconnects, or the client closes. The SDK stops awaiting cancelled callbacks and ignores their later answers. A throwing callback reports a provider error; missing or malformed answers never count as approval. Without a callback, native in-process answerers remain available, and a missing answerer reports `NO_PROVIDER`. + ----- @@ -127,7 +134,7 @@ These limits define when the client is a poor fit or needs special care. They ar - **No bundled-runtime resolution** — the client resolves the same-version `@deepseek-ai/dsh` package (or a caller-provided `dshBin`); packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists. - **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the [protocol limitations](../protocol/README.md#known-limitations-and-deferred-work)). - **No per-prompt result** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime. -- **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows. +- **Callbacks must release their own resources** — the SDK delivers cancellation through `AbortSignal`; application work that ignores it may continue after the SDK stops awaiting its answer. ### Dev Note diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index 2dd38f0251..26cdd1b078 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -59,6 +59,13 @@ console.log(result.finalResponse) 本客户端为每种失败模式导出类型化错误:`JsonRpcResponseError`(协议错误响应,保留 code 与 data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯直到进程退出;幂等,已关闭的客户端拒绝复用。`HarnessClientOptions.env` 给定时整体替换子进程环境(`undefined` 原样继承父进程环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。 + +### 原生用户问答 + +向 `DeepSeekHarness` 或 `HarnessClient` 传入 `onUserQuestion`,即可通过原生 SDK 协议回答内建的 `ask_user_question` 工具。回调接收 `{ requestId, sessionId, questions }` 与 `{ signal }`;返回 `{ answers: [{ id, selected, custom? }] }`,每个问题 id 对应一项。应用自行提供 UI 或模拟用户策略。SDK 在初始化时自动声明此回调,也能在其他请求待决时接收答案。 + +运行时取消问题、断开连接或客户端关闭时,应响应 signal 释放宿主工作。SDK 停止等待已取消的回调,并忽略晚到答案。回调抛错会报告提供方错误;缺失或格式错误的答案均不构成批准。未提供回调时,原生进程内回答方仍可用,缺少回答方则报告 `NO_PROVIDER`。 + ----- @@ -127,7 +134,7 @@ console.log(result.finalResponse) - **无捆绑运行时解析**——客户端解析同版本 `@deepseek-ai/dsh` 包(或调用方提供的 `dshBin`);打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。 - **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见[协议限制](../protocol/README.zh.md#known-limitations-and-deferred-work))。 - **没有逐提示词结果**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执到 idle 的收集,放弃该过程意味着关闭运行时。 -- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流程保留了承载能力。 +- **回调必须释放自己的资源**——SDK 通过 `AbortSignal` 发出取消;忽略它的应用工作可能在 SDK 停止等待答案后继续运行。 ### 开发备注 diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index 632e06d872..0714e6c6a8 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -20,6 +20,7 @@ import { type InitializeResult, type SessionPromptParams, type SdkPromptContentBlock, + type SdkUserQuestionRequest, } from '@deepseek-ai/dsh-sdk-protocol' import { disposeRuntimeProcess } from './dispose.ts' import { resolveDshLaunch, type RuntimeProcessOptions } from './launch.ts' @@ -196,6 +197,8 @@ export class HarnessClient { private spawnError: Error | undefined private streamsSettled: Promise = Promise.resolve() private closeTask: Promise | undefined + private readonly userQuestions = new Map() + private userQuestionHandler: HarnessClientOptions['onUserQuestion'] /** @param options - dsh profile, patch, home, process, environment, and timeout options. */ constructor(options?: HarnessClientOptions) @@ -211,6 +214,7 @@ export class HarnessClient { start(): void { if (this.closeTask !== undefined) throw new TransportClosedError('DeepSeek Harness runtime client is closed') if (this.child !== undefined) return + this.userQuestionHandler = this.options.onUserQuestion const child = spawn(this.runtime.command, this.runtime.args, { cwd: this.runtime.cwd, env: this.runtime.environment(), @@ -263,7 +267,39 @@ export class HarnessClient { this.transport?.close() }) const transport = new JsonRpcLineTransport(child.stdout, child.stdin) - transport.onNotification((method, params) => { this.dispatchNotification({ method, params }) }) + transport.onRequest(async (method, params) => { + if (method !== 'user-question/ask') throw new SdkProtocolError(`unknown runtime request: ${method}`) + const handler = this.userQuestionHandler + if (handler === undefined) throw new SdkProtocolError('user-question/ask requires an onUserQuestion callback') + if (!isUserQuestionRequest(params)) { + throw new SdkProtocolError('user-question/ask requires requestId, sessionId, and questions') + } + const requestId = params.requestId + if (this.userQuestions.has(requestId)) throw new SdkProtocolError('duplicate user-question requestId') + const controller = new AbortController() + this.userQuestions.set(requestId, controller) + const cancelled = Promise.withResolvers() + const onAbort = (): void => { cancelled.reject(controller.signal.reason) } + controller.signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([ + Promise.resolve().then(() => { + controller.signal.throwIfAborted() + return handler(params, { signal: controller.signal }) + }), + cancelled.promise, + ]) + } finally { + controller.signal.removeEventListener('abort', onAbort) + this.userQuestions.delete(requestId) + } + }) + transport.onNotification((method, params) => { + if (method === 'user-question.cancelled' && typeof params.requestId === 'string') { + this.userQuestions.get(params.requestId)?.abort(new Error('SDK runtime cancelled the user question')) + } + this.dispatchNotification({ method, params }) + }) transport.start() this.transport = transport } @@ -274,7 +310,10 @@ export class HarnessClient { * @returns the runtime's wire identity. */ async initialize(params: InitializeParams): Promise { - const result = await this.request('initialize', { ...params }, this.runtime.initializeTimeoutMs) + const result = await this.request('initialize', { + ...params, + ...this.userQuestionHandler === undefined ? {} : { capabilities: { ...params.capabilities, userQuestions: true } }, + }, this.runtime.initializeTimeoutMs) if (!isRecord(result) || !isRecord(result.serverInfo) || typeof result.serverInfo.name !== 'string' || typeof result.serverInfo.version !== 'string') { throw new SdkProtocolError(`initialize returned no server identity: ${JSON.stringify(result)}`) @@ -428,6 +467,7 @@ export class HarnessClient { } private async performClose(): Promise { + this.abortUserQuestions(new TransportClosedError('SDK client is closing')) const child = this.child if (child === undefined) return try { @@ -475,9 +515,14 @@ export class HarnessClient { } private failSubscriptions(error: Error): void { + this.abortUserQuestions(error) for (const subscription of this.subscriptions.values()) subscription.fail(error) } + private abortUserQuestions(error: Error): void { + for (const controller of this.userQuestions.values()) controller.abort(error) + } + private appendStderr(lines: string[]): void { const kept = lines.filter(line => line.length > 0) this.stderrTail.push(...kept) @@ -525,3 +570,11 @@ function errorMessage(error: unknown): string { /* v8 ignore next -- the transport and dispose ladder reject only with Errors */ return error instanceof Error ? error.message : String(error) } + +/** Check the question envelope before handing runtime input to application code. */ +function isUserQuestionRequest(value: unknown): value is SdkUserQuestionRequest { + return isRecord(value) && typeof value.requestId === 'string' && value.requestId.length > 0 + && typeof value.sessionId === 'string' && Array.isArray(value.questions) + && value.questions.length > 0 && value.questions.every((question: unknown) => isRecord(question) + && typeof question.id === 'string' && typeof question.question === 'string') +} diff --git a/packages/sdk/client/src/index.ts b/packages/sdk/client/src/index.ts index 4cab9b6300..eaaed46afb 100644 --- a/packages/sdk/client/src/index.ts +++ b/packages/sdk/client/src/index.ts @@ -22,6 +22,8 @@ export { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol' export type { ContentBlock, SdkPromptContentBlock, + SdkUserQuestionAnswer, + SdkUserQuestionRequest, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, diff --git a/packages/sdk/client/src/types.ts b/packages/sdk/client/src/types.ts index 9e91eb1036..5cc34381cd 100644 --- a/packages/sdk/client/src/types.ts +++ b/packages/sdk/client/src/types.ts @@ -6,7 +6,7 @@ */ import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { SdkPromptContentBlock } from '@deepseek-ai/dsh-sdk-protocol' +import type { SdkPromptContentBlock, SdkUserQuestionAnswer, SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-protocol' import type { SessionEvent } from '@deepseek-ai/dsh-session' /** One server-to-client notification as received off the wire. */ @@ -22,6 +22,8 @@ export type NotificationFilter = (notification: HarnessNotification) => boolean /** Launch and timeout options for {@link HarnessClient}. */ export interface HarnessClientOptions { + /** Answer runtime questions without prescribing a UI; observe the signal to release host work on cancellation. */ + onUserQuestion?: (request: SdkUserQuestionRequest, context: { signal: AbortSignal }) => Promise /** Absolute or caller-relative dsh CLI module; omitted resolves this package's same-version dependency. */ dshBin?: string /** Named profile serving the SDK protocol (default `sdk`). */ @@ -81,3 +83,4 @@ export interface RunResult { /** Re-exported content-block alias so SDK callers need no extra import. */ export type { ContentBlock } export type { SdkPromptContentBlock } +export type { SdkUserQuestionAnswer, SdkUserQuestionRequest } diff --git a/packages/sdk/client/tests/fixtures/user-questions.ts b/packages/sdk/client/tests/fixtures/user-questions.ts new file mode 100644 index 0000000000..270b99d918 --- /dev/null +++ b/packages/sdk/client/tests/fixtures/user-questions.ts @@ -0,0 +1,43 @@ +/** Protocol-only peer: requests host input while an unrelated client request is pending. */ +import { createInterface } from 'node:readline' + +const pending = new Map() +let serial = 0 +const input = createInterface({ input: process.stdin }) +function write(value: object): void { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...value })}\n`) } +input.on('line', (line) => { + const frame = JSON.parse(line) as { + id: string | number + method?: string + params?: Record + result?: unknown + error?: unknown + } + if (frame.method === undefined) { + const original = pending.get(String(frame.id)) + if (original !== undefined) { + pending.delete(String(frame.id)) + write({ id: original, result: { answer: frame.result ?? null, error: frame.error ?? null } }) + } + return + } + if (frame.method === 'initialize') { + write({ id: frame.id, result: { serverInfo: { name: 'question-test', version: JSON.stringify(frame.params?.capabilities ?? null) } } }) + } else if (frame.method === 'test/question') { + const id = `host-${String(++serial)}` + pending.set(id, frame.id) + write({ id, method: frame.params?.method ?? 'user-question/ask', params: { + requestId: typeof frame.params?.requestId === 'string' ? frame.params.requestId : id, sessionId: 'main', + questions: frame.params?.questions ?? [{ id: 'format', question: 'Which format?' }], + } }) + } else if (frame.method === 'test/cancel') { + write({ method: 'user-question.cancelled', params: frame.params }) + write({ id: frame.id, result: {} }) + } else if (frame.method === 'test/disconnect') { + process.exit(0) + } else if (frame.method === 'shutdown') { + write({ id: frame.id, result: {} }) + input.close() + process.stdin.destroy() + } +}) diff --git a/packages/sdk/client/tests/user-questions.spec.ts b/packages/sdk/client/tests/user-questions.spec.ts new file mode 100644 index 0000000000..d49f0f0458 --- /dev/null +++ b/packages/sdk/client/tests/user-questions.spec.ts @@ -0,0 +1,100 @@ +import { fileURLToPath } from 'node:url' +import { expect, it, onTestFinished } from 'vitest' +import type { HarnessClientOptions } from '../src/types.ts' +import { createProcessHarnessClient } from '../src/client.ts' + +async function client(onUserQuestion?: HarnessClientOptions['onUserQuestion']) { + const result = createProcessHarnessClient({ + command: process.execPath, + args: [fileURLToPath(new URL('./fixtures/user-questions.ts', import.meta.url))], + environment: () => ({ ...process.env }), + description: 'question protocol fixture', initializeTimeoutMs: 5000, requestTimeoutMs: 5000, + }) + onTestFinished(() => result.close()) + if (onUserQuestion !== undefined) result.options.onUserQuestion = onUserQuestion + result.start() + const initialized = await result.initialize({ cwd: process.cwd(), provider: 'mock', model: 'mock' }) + const capabilities: unknown = JSON.parse(initialized.serverInfo.version) + return { client: result, capabilities } +} + +it('advertises a callback and answers a runtime request during an outbound request', async () => { + const answer = { answers: [{ id: 'format', selected: ['CSV'] }] } + const seen: string[] = [] + const f = await client(async (request, { signal }) => { + seen.push(request.sessionId) + expect(signal.aborted).toBe(false) + return answer + }) + expect(f.capabilities).toEqual({ userQuestions: true }) + await expect(f.client.request('test/question', {})).resolves.toEqual({ answer, error: null }) + expect(seen).toEqual(['main']) +}) + +it('does not advertise a missing callback and reports an unexpected question instead of hanging', async () => { + const f = await client() + expect(f.capabilities).toBeNull() + await expect(f.client.request('test/question', {})).resolves.toMatchObject({ error: { message: expect.stringContaining('onUserQuestion') as string } }) +}) + +it.each([ + { params: { method: 'unexpected/request' }, message: 'unknown runtime request' }, + { params: { questions: [{ question: 'Missing identity' }] }, message: 'requires requestId, sessionId, and questions' }, +])('rejects unsupported or malformed runtime requests: $message', async ({ params, message }) => { + const f = await client(async () => { throw new Error('invalid requests must not reach the host') }) + await expect(f.client.request('test/question', params)).resolves.toMatchObject({ + error: { message: expect.stringContaining(message) as string }, + }) +}) + +it('rejects a duplicate question identity without cancelling the original host callback', async () => { + const entered = Promise.withResolvers() + const answer = { answers: [{ id: 'format', selected: ['CSV'] }] } + const result = Promise.withResolvers() + const f = await client(async (_request, { signal }) => { + entered.resolve(signal) + return result.promise + }) + const original = f.client.request('test/question', { requestId: 'shared' }) + const signal = await entered.promise + await expect(f.client.request('test/question', { requestId: 'shared' })).resolves.toMatchObject({ + error: { message: expect.stringContaining('duplicate') as string }, + }) + expect(signal.aborted).toBe(false) + result.resolve(answer) + await expect(original).resolves.toEqual({ answer, error: null }) +}) + +it('notifies a callback of cancellation and ignores its later answer', async () => { + const entered = Promise.withResolvers() + const lateAnswer = Promise.withResolvers<{ answers: { id: string; selected: string[] }[] }>() + const f = await client(async (_request, { signal }) => { + entered.resolve(signal) + return lateAnswer.promise + }) + const pending = f.client.request('test/question', { requestId: 'cancel-me' }) + const signal = await entered.promise + await f.client.request('test/cancel', { requestId: 'cancel-me', sessionId: 'main' }) + expect(signal.aborted).toBe(true) + await expect(pending).resolves.toMatchObject({ answer: null, error: { message: expect.stringContaining('cancelled') as string } }) + lateAnswer.resolve({ answers: [{ id: 'format', selected: ['late'] }] }) + await f.client.close() +}) + +it.each(['close', 'disconnect'] as const)('aborts host work when the client or runtime %ss', async (kind) => { + const entered = Promise.withResolvers() + const stopped = Promise.withResolvers() + const f = await client(async (_request, { signal }) => { + entered.resolve(signal) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { stopped.resolve(undefined); reject(new Error('question cancelled')) }, { once: true }) + }) + }) + const pending = f.client.request('test/question', {}).catch(() => undefined) + const signal = await entered.promise + if (kind === 'close') await f.client.close() + else await f.client.request('test/disconnect', {}).catch(() => undefined) + await stopped.promise + expect(signal.aborted).toBe(true) + await pending +}) diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index aa6e00701a..c43d1484af 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -21,8 +21,11 @@ en: be53c71505c0d1b4 zh: 6210dee577715539 /deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods: - en: 8057053746fc2755 - zh: 36d5989488c1a351 + en: 89ec24feb09e173c + zh: 4072cfc63b5e25e4 +/deepseek-ai-dsh-sdk-protocol/use-this-package/native-user-questions: + en: 9dcd999ae8c66b71 + zh: 174c5fe0cbb0a9e4 /deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics: en: 3cf5d4ffbaf37f69 zh: 23c4450e31c91d67 @@ -48,8 +51,8 @@ en: ca75c51c89c2c9b0 zh: 696d26adaf4eda6d /deepseek-ai-dsh-sdk-protocol/known-limitations-and-deferred-work: - en: c83a6b621a8f8793 - zh: 32accb03ee0b8ac8 + en: 7146ace77de7e19c + zh: 5eb747177357bb54 /deepseek-ai-dsh-sdk-protocol/known-limitations-and-deferred-work/dev-note: en: 951fa06249790ac1 zh: da66e7f53df3ab00 diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 2b9ff7eacf..3b8d7c4674 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -33,7 +33,7 @@ Wire one JSON-RPC 2.0 message per `\n`-terminated line over byte streams you own ### The SDK methods -Both wire ends share one method set: seven client-to-server requests and four server-to-client notifications. +Both wire ends share one method set: seven client-to-server requests, one server-to-client request, and five server-to-client notifications. | Direction | Method | Payload types | |---|---|---| @@ -44,17 +44,26 @@ Both wire ends share one method set: seven client-to-server requests and four se | client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | client→server | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | | client→server | `shutdown` | no params → `{}` | +| server→client | `user-question/ask` | `SdkUserQuestionRequest` → `SdkUserQuestionAnswer` | +| server→client | `user-question.cancelled` | `SdkUserQuestionCancelled` | | server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) | | server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | -`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these shapes by method name; the package root exports them together with the transport. +`HarnessSdkRequestMap`, `HarnessSdkHostRequestMap`, and `HarnessSdkNotificationMap` index these shapes by method name; the package root exports them together with the transport. `session/wait` accepts `{ sessionId }` for an existing SDK-owned session and returns `{}` after the root stays idle and progressing managed descendant work finishes. Idle descendants with parked input remain resident without blocking this wait. Unknown ids and live Agent failures without a newer committed terminal are rejected. Notifications committed during the wait precede its response on the same transport; raw `session.status` transitions retain their whole-agent meaning. `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. + +### Native user questions + +An answering host sets `initialize.capabilities.userQuestions: true`. The runtime sends `user-question/ask` with `{ requestId, sessionId, questions }`; questions retain their original ids, options, detail, and presentation intent. The host returns `{ answers: [{ id, selected, custom? }] }`, naming every question id exactly once. Invalid batches fail with `BAD_ANSWER`; host or transport failures fail with `PROVIDER_ERROR`. The SDK does not synthesize answers. + +`user-question.cancelled` identifies the request that stopped waiting. The host cancels its own work and discards the question; the runtime abandons its pending RPC and ignores late responses. Without the advertised capability, existing in-process answerers remain responsible and an absent answerer reports `NO_PROVIDER`. This flow requires no GUI. + ### Payload semantics `SessionPromptResult.messageId` identifies the queued user message; it does not identify a later assistant message, turn ending, or prompt result. `SdkPromptContentBlock` accepts ordinary durable content plus `SdkEncodedImageBlock { type: "image", data, mimeType }`; the server converts encoded images to durable references before enqueue. `InitializeParams.reasoningEffort` is an optional non-empty adapter-owned identifier for the selected provider/model route; omission preserves that model's default. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission lets the selected adapter's exact-model default apply. The server resolves the exact route during initialization and rejects `session/prompt` until that handshake succeeds, so a missing adapter, unavailable model, or unsupported effort cannot fall back to constructor defaults. `SubagentFinishedNotification.lastAssistantMessage` carries the child's last non-empty assistant message, or its accumulated assistant text when no such message exists; the field is absent when the child produced neither. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. Notification payloads depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`), so the session vocabulary is part of the wire contract. @@ -119,7 +128,7 @@ These limits define what the protocol does not cover or promise. They are curren - **No protocol-version negotiation** — the handshake carries only `serverInfo.version` (`0.0.1`, unvalidated by clients); pre-release stance, no compatibility promise. - **No cancel or session-close methods** — a client abandons a turn by closing the runtime process; see the [JSON-RPC serving plugin](../server/README.md). -- **Server→client requests are a dead capability** — the transport supports them, but the server never sends one; the Python SDK's responder surface exists for future approval flows. +- **Question waits are process-local** — question request ids are not durable handles for replying after runtime restart. Session history retains completed tool results through its ordinary persistence path. ### Dev Note diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index ebafcdccb0..3754b08ca5 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -33,7 +33,7 @@ kind: "package-library" ### SDK 方法 -两个协议端共享同一套方法:七个客户端到服务端请求与四个服务端到客户端通知。 +两个协议端共享同一套方法:七个客户端到服务端请求、一个服务端到客户端请求与五个服务端到客户端通知。 | 方向 | 方法 | 载荷类型 | |---|---|---| @@ -44,17 +44,26 @@ kind: "package-library" | 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | 客户端→服务端 | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | | client→server | `shutdown` | 无参数 → `{}` | +| server→client | `user-question/ask` | `SdkUserQuestionRequest` → `SdkUserQuestionAnswer` | +| server→client | `user-question.cancelled` | `SdkUserQuestionCancelled` | | server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) | | server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) | | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | -`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。 +`HarnessSdkRequestMap`、`HarnessSdkHostRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。 `session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。 `session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{}`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 + +### 原生用户问答 + +提供回答能力的宿主设置 `initialize.capabilities.userQuestions: true`。运行时发送带有 `{ requestId, sessionId, questions }` 的 `user-question/ask`;问题保留原始 id、选项、detail 与展示意图。宿主返回 `{ answers: [{ id, selected, custom? }] }`,每个问题 id 必须恰好出现一次。无效答案批次返回 `BAD_ANSWER`,宿主或传输失败返回 `PROVIDER_ERROR`。SDK 不生成替代答案。 + +`user-question.cancelled` 标识已停止等待的请求。宿主取消自己的工作并丢弃问题;运行时放弃对应的待决 RPC,忽略晚到响应。未声明该能力时,仍由现有进程内回答方处理;缺少回答方则报告 `NO_PROVIDER`。此流程不要求 GUI。 + ### 载荷语义 `SessionPromptResult.messageId` 标识已排队的用户消息;它不标识后续的助手消息、轮次结束或提示词结果。`SdkPromptContentBlock` 接受普通持久内容以及 `SdkEncodedImageBlock { type: "image", data, mimeType }`;服务器在入队前把编码图像转换为持久引用。`InitializeParams.reasoningEffort` 是所选提供方/模型路由可选的非空适配器自有标识符;省略时保留该模型的默认值。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时应用所选适配器的确切模型默认值。服务器会在初始化期间解析确切路由,并在握手成功前拒绝 `session/prompt`,因此缺少适配器、模型不可用或推理强度不受支持时,不会回退到构造期默认值。`SubagentFinishedNotification.lastAssistantMessage` 携带子 agent 最后一条非空 assistant 消息;若不存在这类消息,则携带其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。通知载荷依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`),因此会话词汇是协议格式约定的一部分。 @@ -119,7 +128,7 @@ kind: "package-library" - **无协议版本协商**——握手只携带 `serverInfo.version`(`0.0.1`,客户端不校验);处于预发布阶段,无兼容承诺。 - **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [JSON-RPC 服务插件](../server/README.zh.md)。 -- **server→client 请求是未使用的能力**——传输层支持,但服务器从不发送;Python SDK 的应答接口为未来审批流程预留。 +- **问题等待仅属于当前进程**——问题请求 id 不能在运行时重启后继续用于回答。已完成的工具结果通过普通持久化路径保留在 Session 历史中。 ### 开发备注 diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 36e1a687cc..d7b14ed917 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -29,12 +29,14 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-user-questions": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-user-questions": "workspace:*" } } diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts index 698ecb6360..d577d6fab1 100644 --- a/packages/sdk/protocol/src/index.ts +++ b/packages/sdk/protocol/src/index.ts @@ -13,11 +13,15 @@ export type { JsonRpcTransportPeer } from './transport.ts' export type { HarnessSdkNotificationMap, HarnessSdkRequestMap, + HarnessSdkHostRequestMap, InitializeParams, InitializeResult, SdkEncodedImageBlock, SdkPromptContentBlock, SdkRunStatus, + SdkUserQuestionRequest, + SdkUserQuestionAnswer, + SdkUserQuestionCancelled, SessionEventNotification, SessionStatusNotification, SessionPromptParams, diff --git a/packages/sdk/protocol/src/transport.ts b/packages/sdk/protocol/src/transport.ts index 36574f46bf..5668d2998b 100644 --- a/packages/sdk/protocol/src/transport.ts +++ b/packages/sdk/protocol/src/transport.ts @@ -36,10 +36,11 @@ export interface JsonRpcTransportPeer { * Send a request and await its response. * @param method - the JSON-RPC method name. * @param params - the request parameters object. + * @param signal - optional cancellation; removes the pending request and ignores late responses. * @returns the result; rejects with {@link JsonRpcResponseError} on an error * response, and with a plain `Error` on a write failure or closure. */ - request(method: string, params: object): Promise + request(method: string, params: object, signal?: AbortSignal): Promise /** * Send a notification; omitted params produce no `params` member. * @param method - the JSON-RPC method name. diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index 25891197e2..9b36a8c257 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -11,6 +11,7 @@ import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import type { AskUserQuestionAnswer, AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types' /** Parameters for the process-wide SDK handshake. */ export interface InitializeParams { @@ -24,6 +25,34 @@ export interface InitializeParams { reasoningEffort?: ReasoningEffortId /** Optional positive output-token cap inherited by SDK-created agents and their in-process descendants. */ maxTokens?: number + /** Host-provided capabilities; omission retains native in-process answerers. */ + capabilities?: { userQuestions?: boolean } +} + +/** Runtime-to-host request for one SDK-owned Session's structured user input. */ +export interface SdkUserQuestionRequest { + /** Correlates cancellation with this request independently of the JSON-RPC id. */ + requestId: string + /** Session whose tool or approval is waiting. */ + sessionId: string + /** Original questions, including any plan-review detail and presentation intent. */ + questions: AskUserQuestionItem[] +} + +/** Host answers, one item per original question id. */ +export type SdkUserQuestionAnswer = AskUserQuestionAnswer + +/** Runtime cancellation of a pending host question. Late answers are ignored. */ +export interface SdkUserQuestionCancelled { + /** Identity from the original question request. */ + requestId: string + /** Session that stopped waiting. */ + sessionId: string +} + +/** Runtime-to-host requests, handled independently of outbound Session requests. */ +export interface HarnessSdkHostRequestMap { + 'user-question/ask': { params: SdkUserQuestionRequest; result: SdkUserQuestionAnswer } } /** Wire-stable server identity returned by initialization. */ @@ -135,6 +164,7 @@ export interface SubagentFinishedNotification { /** Server-to-client notifications by JSON-RPC method name. */ export interface HarnessSdkNotificationMap { + 'user-question.cancelled': SdkUserQuestionCancelled 'session.event': SessionEventNotification 'session.status': SessionStatusNotification 'subagent.started': SubagentStartedNotification diff --git a/packages/sdk/protocol/tsconfig.json b/packages/sdk/protocol/tsconfig.json index 494fb20806..415bb32b7f 100644 --- a/packages/sdk/protocol/tsconfig.json +++ b/packages/sdk/protocol/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../subagent/subagent" + }, + { + "path": "../../interaction/user-questions" } ] } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 2f62e76afe..9094d95cc0 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: e136ff3a0b42cc22 - zh: f6c4df5bbafa335f + en: c39393999db960c0 + zh: 1d6a8460684ae97a /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 @@ -27,8 +27,11 @@ en: 784b68b5a498f2d5 zh: 2c3c5a1490845179 /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/what-sdk-clients-can-do: - en: b4351b6b407fe65f - zh: 52d711d554d2ad33 + en: 3e657fa4c9586454 + zh: 26d572f91e124cc5 +/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/native-user-questions: + en: 5100c311c8476f93 + zh: 60d4bc3165731246 /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/shutdown-and-exit: en: 08c3858f53090f39 zh: dfae960e11515b06 @@ -39,8 +42,8 @@ en: e83e01184c1a45ee zh: d54ed126d31ee6f1 /deepseek-ai-dsh-sdk-jsonrpc-server/understand-the-implementation/source-map: - en: 9ef78eca4d95d97c - zh: 45ff05f4d6c1cd0b + en: 783bd2b6628c0680 + zh: ca5b6b730893ddec /deepseek-ai-dsh-sdk-jsonrpc-server/understand-the-implementation/request-flow: en: 2658804b2f90b135 zh: 085aa00c8f0bbbcd diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index bc78c02cdb..86f07703b6 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) `session/resume` accepts a persisted `{ sessionId }` after initialization and returns `{}` once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while applying the initialized model route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications. +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/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. Mount this plugin when a runtime must serve SDK clients: add it to a `cordis.yml` that composes the agent service, boot the runtime, and clients connect over stdio. The common path is explicit — the plugin needs the `agents` service; every other capability comes from the surrounding tree. @@ -53,6 +55,11 @@ Stdout carries only JSON-RPC frames, so clients can parse every byte; diagnostic `session/wait` observes an existing SDK-owned session through managed descendant completion and any resulting root turns. It rejects unknown or retired roots, never creates a session, and responds after the root stays idle across the descendant check. Idle children with parked inbox messages remain resident but do not delay this response; later waking input can resume them. Session notifications emitted before completion precede the response on the stdio transport. A live Agent failure remains associated with that exact session until a later committed `turn/end` accounts for the outcome; otherwise the wait rejects, including when the failure preceded the wait request. Errors already represented by a terminal remain in Session events, and later successful activity clears an earlier live failure. + +### Native user questions + +An initialized client advertising `capabilities.userQuestions` handles questions for this server's exact owned root Agents. The server forwards the shared `user-questions/request` event as a `user-question/ask` RPC and validates the answer before a tool or approval consumes it. Foreign Agents and clients without that capability retain their existing native answerers. Tool cancellation and server shutdown abort pending requests and notify the host; connection failure returns an error. The [protocol](../protocol/README.md#native-user-questions) defines the wire shapes and failure codes. + ### Shutdown and exit The plugin answers `shutdown`, flushes the response, disposes the root context so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits 0. EOF and signal exits belong to the app bin, which also disposes the root context. Unloading only this plugin stops serving without exiting the process. @@ -76,6 +83,7 @@ The plugin is a thin presentation adapter: [`HarnessSdkJsonRpcServer`](src/serve | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, stdio wiring, request dispatch, shared shutdown/exit task | +| [`src/user-questions.ts`](src/user-questions.ts) | Native question adapter: host forwarding, answer validation, cancellation | | [`src/server.ts`](src/server.ts) | `HarnessSdkJsonRpcServer`: protocol methods, per-session agent creation, lifecycle subscriptions, teardown | ### Request flow diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 772ae98e6f..12842e50ca 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" `session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回 `{}`。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。 +插件在注册提醒激活处理方后发布 `sdkSessionActivation` 就绪标记;SDK 组合让 Schedule 依赖此标记,而非 Web Session controller。定时提醒也通过同一个 SDK Session 所有者投递。启动时已到期的提醒等待 SDK 成功初始化,再使用初始化指定的提供方、模型、推理强度和输出上限恢复会话。客户端可以首次接管提醒恢复的 Session;并发的提醒与客户端恢复共用一个 handle。关闭时释放等待初始化的提醒,并等待已经接纳的恢复完成。 + `session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。 @@ -53,6 +55,11 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 `session/wait` 观察已有 SDK 会话,直到受管理后代及其触发的根 Agent 后续轮次完成。它拒绝未知或已释放的根 Agent,不会创建会话,并在后代检查期间根 Agent 持续空闲后响应。收件箱消息处于停放状态的空闲子级继续驻留,但不延迟此响应;后续唤醒输入可使其继续运行。完成前发出的会话通知在 stdio 传输上先于响应到达。Agent 的运行时错误会保留在对应的确切会话中,直到后续提交的 `turn/end` 记录结果;否则等待请求会失败,即使错误发生在等待请求之前。终止事件已经记录的错误仍通过会话事件表达,后续成功的活动会清除较早的运行时错误。 + +### 原生用户问答 + +已初始化且声明 `capabilities.userQuestions` 的客户端负责此服务器所拥有的确切根 Agent 的问题。服务器把共享的 `user-questions/request` 事件转为 `user-question/ask` RPC,并在工具或审批消费答案前校验。其他 Agent 和未声明此能力的客户端仍使用原生回答方。工具取消和服务器关闭会中止待决请求并通知宿主;连接失败则返回错误。[协议](../protocol/README.zh.md#native-user-questions)定义载荷结构和错误码。 + ### 关闭与退出 插件应答 `shutdown`,刷新响应并 dispose 根上下文,使 SDK 持有的 agent、订阅与持久化达到完全停稳,然后以 0 退出。EOF 与信号退出归 app bin 负责,后者也会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。 @@ -76,6 +83,7 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、stdio 接线、请求分发、共享关闭与退出任务 | +| [`src/user-questions.ts`](src/user-questions.ts) | 原生问答适配器:宿主转发、答案校验、取消 | | [`src/server.ts`](src/server.ts) | `HarnessSdkJsonRpcServer`:协议方法、逐会话 agent 创建、生命周期订阅、清理 | ### 请求流程 diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 272fb47150..5c23674e81 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -36,11 +36,13 @@ "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-sdk-protocol": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-user-questions": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -51,12 +53,14 @@ "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-sdk-protocol": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@deepseek-ai/dsh-user-questions": "workspace:*" } } diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 6fdd9398a9..3fa3759550 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -59,6 +59,9 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const server = new HarnessSdkJsonRpcServer(ctx, transport, { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, }) + // Compositions can order Schedule after its activation handler is installed. + // Like the app startup marker, this carries readiness, not another control API. + ctx.provide('sdkSessionActivation', { ready: true }) // Share one exit task so racing shutdown requests cannot dispose the root or // exit the process more than once. diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index 726f430f15..3ff22a589e 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -7,6 +7,7 @@ import type { Context } from '@deepseek-ai/cordis' import { resolve } from 'node:path' +import { SdkUserQuestionBridge } from './user-questions.ts' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { admitEncodedImages, type EncodedImageAttachment, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -14,6 +15,7 @@ import { createUserMessage, ReasoningEffortId, type ContentBlock, type LlmRuntim import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import type { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-working-directory' +import type {} from '@deepseek-ai/dsh-schedule' import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' @@ -93,6 +95,10 @@ export class HarnessSdkJsonRpcServer { private shutdownTask: Promise> | undefined private shuttingDown = false private initialized = false + private readonly userQuestions: SdkUserQuestionBridge + private readonly initialization = Promise.withResolvers() + /** Restored by a reminder and available for the client's first resume. */ + private readonly scheduledSessions = new Map>() constructor( private readonly ctx: Context, @@ -100,6 +106,28 @@ export class HarnessSdkJsonRpcServer { private readonly options: HarnessSdkJsonRpcServerOptions = {}, ) { const serverOptions = this.options + this.userQuestions = new SdkUserQuestionBridge(ctx, transport, agent => + this.sessions.get(agent.session.id)?.handle.agent === agent) + this.disposers.push(ctx.on('schedule/resolve-agent', async (sessionId) => { + // Startup delivery must wait for the SDK model route and output limit. + await this.initialization.promise + if (this.shuttingDown) throw new Error('SDK server is shutting down') + const existing = this.sessions.get(sessionId) + const pending = this.sessionCreations.get(sessionId) + if (existing !== undefined) { + this.assertLiveAgent(existing, sessionId) + return existing.handle.agent + } + if (pending !== undefined) return (await pending).handle.agent + const restoration = this.openSession(sessionId, true) + this.scheduledSessions.set(sessionId, restoration) + try { + return (await restoration).handle.agent + } catch (error) { + this.scheduledSessions.delete(sessionId) + throw error + } + })) this.disposers.push(ctx.on('session/event', (session, event) => { const record = this.sessions.get(session.id) if (event.type === 'turn/end' && record?.handle.agent.session === session @@ -184,7 +212,9 @@ export class HarnessSdkJsonRpcServer { this.model = model this.reasoningEffort = reasoningEffort this.maxTokens = params.maxTokens + this.userQuestions.enabled = params.capabilities?.userQuestions === true this.initialized = true + this.initialization.resolve() return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } } @@ -223,6 +253,13 @@ 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 sessionId = params.sessionId + const restoration = this.scheduledSessions.get(sessionId) + if (restoration !== undefined) { + this.scheduledSessions.delete(sessionId) + const record = await restoration + this.assertLiveAgent(record, sessionId) + return {} + } if (this.sessions.has(sessionId) || this.sessionCreations.has(sessionId)) { throw new Error(`SDK session is already open or opening: ${sessionId}`) } @@ -302,9 +339,12 @@ export class HarnessSdkJsonRpcServer { private async performShutdown(): Promise> { this.shuttingDown = true + this.userQuestions.close() + this.initialization.resolve() const pendingCreations = [...this.sessionCreations.values()] await Promise.allSettled(pendingCreations) this.sessionCreations.clear() + this.scheduledSessions.clear() const records = [...this.sessions.values()] this.sessions.clear() const failures: unknown[] = [] @@ -378,6 +418,7 @@ export class HarnessSdkJsonRpcServer { private async getOrCreateSession(sessionId: string): Promise { if (this.shuttingDown) throw new Error('SDK server is shutting down') + this.scheduledSessions.delete(sessionId) const existing = this.sessions.get(sessionId) if (existing) return existing const pending = this.sessionCreations.get(sessionId) @@ -422,4 +463,6 @@ export class HarnessSdkJsonRpcServer { private hasAdapterFor(provider: string): boolean { return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false } + + } diff --git a/packages/sdk/server/src/user-questions.ts b/packages/sdk/server/src/user-questions.ts new file mode 100644 index 0000000000..8a5ff783ad --- /dev/null +++ b/packages/sdk/server/src/user-questions.ts @@ -0,0 +1,74 @@ +/** Native SDK adapter for the shared user-questions seam. */ +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { UserQuestionError, type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions' +import type { JsonRpcTransportPeer, SdkUserQuestionAnswer, SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-protocol' + +/** Owns question forwarding and cancellation for one SDK transport. */ +export class SdkUserQuestionBridge { + /** Enabled only after the host advertises an answerer at initialization. */ + enabled = false + private readonly questionShutdown = new AbortController() + private readonly unregister: () => void + + constructor( + private readonly ctx: Context, + private readonly transport: JsonRpcTransportPeer, + ownsAgent: (agent: Agent) => boolean, + ) { + this.unregister = ctx.on('user-questions/request', async (request, next) => { + const agent = request.agent + if (!this.enabled || agent === undefined || !ownsAgent(agent)) return next() + return this.ask(request, String(agent.session.id)) + }) + } + + /** Stop admission and cancel outstanding questions before SDK Agents drain. */ + close(): void { + this.enabled = false + this.unregister() + this.questionShutdown.abort(new UserQuestionError('SDK server is shutting down', 'ASK_ABORTED')) + } + + private async ask(request: AskUserQuestionRequest, sessionId: string): Promise { + const params: SdkUserQuestionRequest = { requestId: randomUUID(), sessionId, questions: request.questions } + const signal = request.signal === undefined ? this.questionShutdown.signal + : AbortSignal.any([request.signal, this.questionShutdown.signal]) + signal.throwIfAborted() + const cancelled = (): void => { + try { + this.transport.notify('user-question.cancelled', { requestId: params.requestId, sessionId }) + } catch (error) { + this.ctx.logger.warn(`SDK question cancellation could not reach the host: ${String(error)}`) + } + } + signal.addEventListener('abort', cancelled, { once: true }) + try { + const result = await this.transport.request('user-question/ask', params, signal) + return parseHostAnswer(result, request.questions.map(question => question.id)) + } catch (error) { + if (error instanceof UserQuestionError) throw error + throw new UserQuestionError(signal.aborted ? 'SDK user question was cancelled' : 'SDK host could not answer the user question', + signal.aborted ? 'ASK_ABORTED' : 'PROVIDER_ERROR', { cause: error }) + } finally { + signal.removeEventListener('abort', cancelled) + } + } +} + +/** Validate the untrusted host response before it enters a tool result or approval decision. */ +function parseHostAnswer(value: unknown, questionIds: readonly string[]): SdkUserQuestionAnswer { + const badAnswer = (): never => { throw new UserQuestionError('SDK host must answer each question id exactly once with selected labels and optional custom text', 'BAD_ANSWER') } + if (value === null || typeof value !== 'object' || !('answers' in value) || !Array.isArray(value.answers)) return badAnswer() + const answers = value.answers.map((answer: unknown) => { + if (answer === null || typeof answer !== 'object' || !('id' in answer) || typeof answer.id !== 'string' + || !('selected' in answer) || !Array.isArray(answer.selected) || !answer.selected.every((label: unknown) => typeof label === 'string') + || ('custom' in answer && typeof answer.custom !== 'string')) return badAnswer() + return { id: answer.id, selected: answer.selected, + ...'custom' in answer ? { custom: answer.custom as string } : {} } + }) + const ids = new Set(answers.map(answer => answer.id)) + if (ids.size !== answers.length || answers.length !== questionIds.length || !questionIds.every(id => ids.has(id))) return badAnswer() + return { answers } +} diff --git a/packages/sdk/server/tests/resume.spec.ts b/packages/sdk/server/tests/resume.spec.ts index 4b1710e6b9..b4c8f2e37b 100644 --- a/packages/sdk/server/tests/resume.spec.ts +++ b/packages/sdk/server/tests/resume.spec.ts @@ -49,6 +49,73 @@ async function initialized(root: string) { } describe('SDK persisted session resume', () => { + it('restores due reminders through the SDK owner after initialization and shares client resume', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'original' }] }) + await first.server.wait({ sessionId: 'saved' }) + await first.server.shutdown() + await first.ctx.fiber.dispose() + + const { ctx, server, adapter } = await runtime(root) + const fallback = vi.fn(async () => { throw new Error('must not activate through the Web controller') }) + const restoring = ctx.waterfall('schedule/resolve-agent', SessionId('saved'), fallback) + expect(ctx.agents.list()).toHaveLength(0) + const actualOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + const opened = Promise.withResolvers() + const gate = Promise.withResolvers() + const open = vi.spyOn(ctx.sessionPersistence, 'open').mockImplementation(async (...args) => { + const handle = await actualOpen(...args) + opened.resolve(undefined) + await gate.promise + return handle + }) + try { + await server.initialize({ cwd: tmpdir(), provider: 'mock', model: 'reminder-model', maxTokens: 1234 }) + await opened.promise + const resuming = server.resume({ sessionId: 'saved' }) + const anotherReminder = ctx.waterfall('schedule/resolve-agent', SessionId('saved'), fallback) + gate.resolve(undefined) + const [agent, sameAgent] = await Promise.all([restoring, anotherReminder, resuming]) + expect(sameAgent).toBe(agent) + expect(open).toHaveBeenCalledTimes(1) + expect(fallback).not.toHaveBeenCalled() + expect(agent.session.header.cwd).toBe(root) + await server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'scheduled reminder' }] }) + await server.wait({ sessionId: 'saved' }) + expect(adapter.requests[0]).toMatchObject({ model: 'reminder-model', maxTokens: 1234 }) + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('already open') + await server.shutdown() + expect(ctx.agents.list()).toHaveLength(0) + } finally { + gate.resolve(undefined) + } + }) + + it('reuses an SDK-owned Session for reminders and releases failed reminder restoration for retry', async () => { + const { ctx, server } = await initialized(await storageRoot()) + const fallback = vi.fn(async () => { throw new Error('unexpected fallback') }) + const restore = () => ctx.waterfall('schedule/resolve-agent', SessionId('saved'), fallback) + await expect(restore()).rejects.toThrow() + await server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'create after failed reminder' }] }) + await server.wait({ sessionId: 'saved' }) + const agent = ctx.agents.get(SessionId('saved')) + await expect(restore()).resolves.toBe(agent) + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('already open') + expect(fallback).not.toHaveBeenCalled() + }) + + it('releases a reminder waiting for initialization when the SDK shuts down', async () => { + const { ctx, server } = await runtime(await storageRoot()) + const restoring = ctx.waterfall('schedule/resolve-agent', SessionId('saved'), async () => { + throw new Error('unexpected fallback') + }) + const rejected = expect(restoring).rejects.toThrow('shutting down') + await server.shutdown() + await rejected + expect(ctx.agents.list()).toHaveLength(0) + }) + it('restores a non-main identity, preserves history and origin, and appends a new turn', async () => { const root = await storageRoot() const first = await initialized(root) diff --git a/packages/sdk/server/tests/user-questions.spec.ts b/packages/sdk/server/tests/user-questions.spec.ts new file mode 100644 index 0000000000..1adf84dd3a --- /dev/null +++ b/packages/sdk/server/tests/user-questions.spec.ts @@ -0,0 +1,138 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import UserQuestions from '@deepseek-ai/dsh-user-questions' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { expect, it, onTestFinished, vi } from 'vitest' +import { HarnessSdkJsonRpcServer } from '../src/server.ts' + +const questions = [{ id: 'format', question: 'Which format?', options: [{ label: 'CSV' }, { label: 'JSON' }] }] +const answer = { answers: [{ id: 'format', selected: ['CSV'], custom: 'include a header' }] } + +async function fixture(request: JsonRpcTransportPeer['request'], hostQuestions = true) { + const root = await mkdtemp(join(tmpdir(), 'sdk-user-questions-')) + const ctx = new Context() + const state: { server?: HarnessSdkJsonRpcServer } = {} + onTestFinished(async () => { + try { await state.server?.shutdown() } finally { + try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) } + } + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(UserQuestions) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('ready')])) + const transport = { request: vi.fn(request), notify: vi.fn() } + const server = state.server = new HarnessSdkJsonRpcServer(ctx, transport) + await server.initialize({ cwd: root, provider: 'mock', model: 'mock', capabilities: { userQuestions: hostQuestions } }) + await server.prompt({ sessionId: 'main', contentBlocks: [{ type: 'text', text: 'start' }] }) + await server.wait({ sessionId: 'main' }) + const agent = ctx.agents.get(SessionId('main'))! + return { ctx, server, transport, agent } +} + +it('forwards the original questions and maps the host answer to the SDK-owned root', async () => { + const f = await fixture(async () => answer) + await expect(f.ctx.userQuestions.ask({ agent: f.agent, questions })).resolves.toEqual(answer) + expect(f.transport.request).toHaveBeenCalledWith('user-question/ask', { + requestId: expect.any(String) as string, sessionId: 'main', questions, + }, expect.any(AbortSignal)) + expect(f.transport.notify).not.toHaveBeenCalledWith('user-question.cancelled', expect.anything()) +}) + +it('keeps native answerers when the SDK host did not advertise question support', async () => { + const f = await fixture(async () => { throw new Error('must not ask the host') }, false) + await expect(f.ctx.userQuestions.ask({ agent: f.agent, questions })).rejects.toMatchObject({ code: 'NO_PROVIDER' }) + f.ctx.on('user-questions/request', async () => answer) + await expect(f.ctx.userQuestions.ask({ agent: f.agent, questions })).resolves.toEqual(answer) + expect(f.transport.request).not.toHaveBeenCalled() +}) + +it('does not forward questions belonging to another runtime root', async () => { + const f = await fixture(async () => answer) + const foreign = await f.ctx.agents.create({ sessionId: SessionId('foreign'), agentOptions: { provider: 'mock', model: 'mock' } }) + try { + await expect(f.ctx.userQuestions.ask({ agent: foreign.agent, questions })).rejects.toMatchObject({ code: 'NO_PROVIDER' }) + expect(f.transport.request).not.toHaveBeenCalled() + } finally { + await foreign.dispose() + } +}) + +it.each([ + null, + 42, + {}, + { answers: false }, + { answers: [null] }, + { answers: ['wrong'] }, + { answers: [{ selected: [] }] }, + { answers: [{ id: 42, selected: [] }] }, + { answers: [{ id: 'format' }] }, + { answers: [{ id: 'format', selected: false }] }, + { answers: [] }, + { answers: [{ id: 'wrong', selected: [] }] }, + { answers: [{ id: 'format', selected: [] }, { id: 'format', selected: [] }] }, + { answers: [{ id: 'format', selected: [1] }] }, + { answers: [{ id: 'format', selected: [], custom: false }] }, +])('rejects an invalid host answer without treating it as approval: %j', async (value) => { + const f = await fixture(async () => value) + await expect(f.ctx.userQuestions.ask({ agent: f.agent, questions })).rejects.toMatchObject({ code: 'BAD_ANSWER' }) +}) + +it.each(['tool', 'shutdown'] as const)('cancels an outstanding host request on %s cancellation', async (kind) => { + const entered = Promise.withResolvers() + const f = await fixture(async (_method, _params, signal) => { + if (signal === undefined) throw new Error('question must have a cancellation signal') + entered.resolve(signal) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('question cancelled')) }, { once: true }) + }) + }) + const controller = new AbortController() + const pending = f.ctx.userQuestions.ask({ agent: f.agent, questions, signal: controller.signal }) + const rejected = expect(pending).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + const signal = await entered.promise + if (kind === 'tool') controller.abort(new Error('user cancelled the tool')) + else await f.server.shutdown() + await rejected + expect(signal.aborted).toBe(true) + expect(f.transport.notify).toHaveBeenCalledWith('user-question.cancelled', { + requestId: expect.any(String) as string, sessionId: 'main', + }) +}) + +it('reports a host or transport failure and does not synthesize an answer', async () => { + const f = await fixture(async () => { throw new Error('host disconnected') }) + await expect(f.ctx.userQuestions.ask({ agent: f.agent, questions })).rejects.toMatchObject({ code: 'PROVIDER_ERROR' }) +}) + +it('contains cancellation-notification write failure while aborting the pending question', async () => { + const entered = Promise.withResolvers() + const f = await fixture(async (_method, _params, signal) => { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) + }) + }) + const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + const notify = f.transport.notify.getMockImplementation() + f.transport.notify.mockImplementation((method) => { + if (method === 'user-question.cancelled') throw new Error('broken output') + }) + const controller = new AbortController() + const rejected = expect(f.ctx.userQuestions.ask({ agent: f.agent, questions, signal: controller.signal })) + .rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await entered.promise + controller.abort(new Error('cancel')) + await rejected + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancellation could not reach the host')) + warn.mockRestore() + if (notify !== undefined) f.transport.notify.mockImplementation(notify) +}) diff --git a/packages/sdk/server/tsconfig.json b/packages/sdk/server/tsconfig.json index 5bd5f18692..65a1aee58a 100644 --- a/packages/sdk/server/tsconfig.json +++ b/packages/sdk/server/tsconfig.json @@ -37,6 +37,12 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../schedule/schedule" + }, + { + "path": "../../interaction/user-questions" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a301a1284a..22d780fdf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2548,9 +2548,6 @@ importers: packages/bundle/sdk-app: dependencies: - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:* - version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:* version: link:../../client/connection @@ -12010,6 +12007,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../../subagent/subagent + '@deepseek-ai/dsh-user-questions': + specifier: workspace:* + version: link:../../interaction/user-questions packages/sdk/server: dependencies: @@ -12044,6 +12044,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek-api-key': specifier: workspace:* version: link:../../llm/llm-deepseek-api-key + '@deepseek-ai/dsh-schedule': + specifier: workspace:* + version: link:../../schedule/schedule '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../../core/scope @@ -12062,6 +12065,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../../subagent/subagent + '@deepseek-ai/dsh-user-questions': + specifier: workspace:* + version: link:../../interaction/user-questions '@deepseek-ai/dsh-working-directory': specifier: workspace:* version: link:../../session/working-directory diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index b0f9b305c7..7069a975f2 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -14,6 +14,9 @@ /deepseek-harness-python-sdk/customize-plugins: en: 69a6a6745c6e89c1 zh: 85e82a4cc31e16e5 +/deepseek-harness-python-sdk/native-user-questions: + en: d9c60c5f43755782 + zh: 78c82635209ed22d /deepseek-harness-python-sdk/results-and-notifications: en: d4f7366c2d260639 zh: 7de99aed2b1bdd0a diff --git a/python/sdk/README.md b/python/sdk/README.md index 9ed3f8d281..3a90b627e5 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -87,6 +87,12 @@ with DeepSeekHarness( The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides a platform-selected persistent shell, `read_image`, `working_directory`, local execution, JSONL sessions, and automatic compaction. Text file tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. SDK prompt text does not execute `/compact`. +## Native user questions + +Pass `on_user_question` to `DeepSeekHarnessConfig`, `DeepSeekHarness`, or `HarnessConfig` to answer the built-in `ask_user_question` tool through the native SDK protocol. The callback receives a `UserQuestionRequest` (`request_id`, `session_id`, and the original `questions`) and a `threading.Event` signalling cancellation. Return `UserQuestionAnswer` or a JSON object shaped as `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}`. Each original question id must appear exactly once. The application supplies its UI or simulated-user policy; the SDK does not choose answers. + +Callbacks run on separate threads so the reader can process cancellation and other requests. They may run concurrently and may make further client requests. Release callback-owned work when the cancellation Event is set. Closing the client signals outstanding callbacks and gives their workers a shared 0.5-second join window; callbacks that ignore cancellation may continue, but their late answers cannot reach the runtime or a later process. A callback exception becomes a protocol error, and an absent callback retains existing native answerers or reports `NO_PROVIDER`. + ## Results and notifications `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 7adf0decbd..18e3beb841 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -87,6 +87,12 @@ with DeepSeekHarness( 随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它提供按平台选择的持久 shell、`read_image`、`working_directory`、本地执行、JSONL 会话和自动压缩。文本文件工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。SDK 提示词文本不会执行 `/compact`。 +## 原生用户问答 + +向 `DeepSeekHarnessConfig`、`DeepSeekHarness` 或 `HarnessConfig` 传入 `on_user_question`,即可通过原生 SDK 协议回答内建的 `ask_user_question` 工具。回调接收 `UserQuestionRequest`(包含 `request_id`、`session_id` 和原始 `questions`)以及表示取消的 `threading.Event`。返回 `UserQuestionAnswer`,或形如 `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}` 的 JSON 对象。每个原问题 id 必须恰好出现一次。应用自行提供 UI 或模拟用户策略;SDK 不选择答案。 + +回调在独立线程执行,读取线程仍可处理取消和其他请求。回调可以并发执行,也可以再次调用客户端请求。取消 Event 置位时应释放回调拥有的工作。客户端关闭会通知所有待决回调,并给其工作线程总计 0.5 秒的 join 窗口;忽略取消的回调可能继续执行,但晚到答案不会发送到运行时或后续新进程。回调异常转为协议错误;未提供回调时保留原生回答方,或报告 `NO_PROVIDER`。 + ## 结果与通知 `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`。 diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py index 8c098224f3..da57aa2051 100644 --- a/python/sdk/src/deepseek_harness/__init__.py +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -1,7 +1,7 @@ from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session -from .client import HarnessClient, HarnessConfig +from .client import HarnessClient, HarnessConfig, UserQuestionHandler from .errors import SdkProtocolError -from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo +from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest __all__ = [ "DeepSeekHarness", @@ -10,6 +10,10 @@ __all__ = [ "RunResult", "HarnessClient", "HarnessConfig", + "UserQuestionHandler", + "UserQuestionRequest", + "UserQuestionAnswer", + "UserQuestionAnswerItem", "SdkProtocolError", "IncomingRequest", "InitializeResponse", diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 0bc5d7211d..798a8878e0 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Callable -from .client import HarnessClient, HarnessConfig +from .client import HarnessClient, HarnessConfig, UserQuestionHandler from .errors import SdkProtocolError from .models import JsonObject, Notification @@ -35,6 +35,7 @@ class DeepSeekHarnessConfig: shutdown_timeout_seconds: float | None = 1.0 base_url: str | None = None api_key: str | None = None + on_user_question: UserQuestionHandler | None = None @dataclass(slots=True) @@ -84,6 +85,7 @@ class DeepSeekHarness: initialize_timeout_seconds=self.config.initialize_timeout_seconds, request_timeout_seconds=self.config.request_timeout_seconds, shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, + on_user_question=self.config.on_user_question, ), _launch_args=_launch_args, ) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 0428e56b46..d9d5d983d7 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -15,10 +15,11 @@ from typing import Callable, TypeAlias, TypeVar from pydantic import BaseModel from .errors import JsonRpcError, SdkProtocolError, TransportClosedError -from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification +from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest ModelT = TypeVar("ModelT", bound=BaseModel) NotificationFilter: TypeAlias = Callable[[Notification], bool] +UserQuestionHandler: TypeAlias = Callable[[UserQuestionRequest, threading.Event], UserQuestionAnswer | JsonObject] @dataclass(slots=True) @@ -34,6 +35,8 @@ class HarnessConfig: initialize_timeout_seconds: float = 30.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 + # Callbacks run outside the reader; observe the Event to release work on cancellation. + on_user_question: UserQuestionHandler | None = None class HarnessClient: @@ -60,6 +63,9 @@ class HarnessClient: self._stderr_lines: deque[str] = deque(maxlen=400) self._reader_thread: threading.Thread | None = None self._stderr_thread: threading.Thread | None = None + self._question_tasks: dict[str, tuple[threading.Event, threading.Thread]] = {} + self._closing = False + self._user_question_handler: UserQuestionHandler | None = None def __enter__(self) -> "HarnessClient": self.start() @@ -73,6 +79,9 @@ class HarnessClient: return with self._lock: self._session_parents.clear() + self._question_tasks.clear() + self._closing = False + self._user_question_handler = self.config.on_user_question env = os.environ.copy() if self.config.env: env.update(self.config.env) @@ -93,6 +102,11 @@ class HarnessClient: def close(self) -> None: """Close the runtime after a bounded opportunity to flush durable state.""" + with self._lock: + self._closing = True + question_tasks = list(self._question_tasks.values()) + for cancelled, _thread in question_tasks: + cancelled.set() proc = self._proc if proc is None: return @@ -129,6 +143,10 @@ class HarnessClient: self._reader_thread.join(timeout=0.5) if self._stderr_thread and self._stderr_thread.is_alive(): self._stderr_thread.join(timeout=0.5) + deadline = time.monotonic() + 0.5 + for _cancelled, thread in question_tasks: + if thread is not threading.current_thread(): + thread.join(timeout=max(0, deadline - time.monotonic())) def initialize( self, @@ -148,6 +166,8 @@ class HarnessClient: payload["reasoningEffort"] = reasoning_effort if max_tokens is not None: payload["maxTokens"] = max_tokens + if self._user_question_handler is not None: + payload["capabilities"] = {"userQuestions": True} try: return self.request( "initialize", @@ -407,7 +427,11 @@ class HarnessClient: method = message.get("method") if isinstance(msg_id, (str, int)) and isinstance(method, str): params = message.get("params") - self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {})) + request = IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {}) + if method == "user-question/ask" and self._user_question_handler is not None: + self._start_user_question(request) + else: + self._requests.put(request) return if isinstance(msg_id, (str, int)): with self._lock: @@ -424,6 +448,10 @@ class HarnessClient: params = message.get("params") notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) with self._lock: + if method == "user-question.cancelled": + task = self._question_tasks.get(str(notification.payload.get("requestId"))) + if task is not None: + task[0].set() self._record_session_relationship_locked(notification) subscribers = list(self._notification_subscribers.items()) delivered = False @@ -445,6 +473,8 @@ class HarnessClient: def _fail_waiters(self, exc: BaseException) -> None: with self._lock: + for cancelled, _thread in self._question_tasks.values(): + cancelled.set() waiters = list(self._responses.values()) self._responses.clear() subscribers = list(self._notification_subscribers.values()) @@ -456,6 +486,56 @@ class HarnessClient: self._notifications.put(exc) self._requests.put(exc) + def _start_user_question(self, incoming: IncomingRequest) -> None: + """Keep the reader free to receive cancellation, other responses, and shutdown.""" + handler = self._user_question_handler + proc = self._proc + try: + request = UserQuestionRequest.model_validate(incoming.payload) + if any(not isinstance(item.get("id"), str) or not isinstance(item.get("question"), str) for item in request.questions): + raise ValueError("questions require id and question strings") + except ValueError: + self.respond_error(incoming.id, code=-32602, message="invalid user-question/ask parameters") + return + cancelled = threading.Event() + + def answer() -> None: + try: + if cancelled.is_set() or handler is None: + return + result = handler(request, cancelled) + wire_result = result.model_dump(exclude_none=True) if isinstance(result, UserQuestionAnswer) else result + with self._lock: + if not cancelled.is_set() and self._proc is proc: + self.respond(incoming.id, wire_result) + except Exception as exc: + with self._lock: + if not cancelled.is_set() and self._proc is proc: + try: + self.respond_error(incoming.id, code=-32603, message=str(exc)) + except TransportClosedError: + cancelled.set() + finally: + with self._lock: + current = self._question_tasks.get(request.request_id) + if current is not None and current[0] is cancelled: + self._question_tasks.pop(request.request_id, None) + + thread = threading.Thread(target=answer, name="dsh-user-question", daemon=True) + with self._lock: + if self._closing: + return + if request.request_id in self._question_tasks: + self.respond_error(incoming.id, code=-32602, message="duplicate user-question requestId") + return + self._question_tasks[request.request_id] = (cancelled, thread) + try: + thread.start() + except BaseException: + self._question_tasks.pop(request.request_id, None) + cancelled.set() + raise + def _runtime_closed_error(self, reason: str) -> TransportClosedError: diagnostics = self._runtime_diagnostics() return TransportClosedError(f"{reason}\n{diagnostics}" if diagnostics else reason) diff --git a/python/sdk/src/deepseek_harness/models.py b/python/sdk/src/deepseek_harness/models.py index f398bb422d..4f0ab071a6 100644 --- a/python/sdk/src/deepseek_harness/models.py +++ b/python/sdk/src/deepseek_harness/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import TypeAlias -from pydantic import BaseModel +from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue JsonScalar: TypeAlias = str | int | float | bool | None JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"] @@ -30,3 +30,25 @@ class ServerInfo(BaseModel): class InitializeResponse(BaseModel): serverInfo: ServerInfo | None = None + + +class UserQuestionRequest(BaseModel): + """Runtime question batch; questions retain their options, detail, and presentation intent.""" + + request_id: str = Field(alias="requestId", min_length=1) + session_id: str = Field(alias="sessionId") + questions: list[dict[str, PydanticJsonValue]] = Field(min_length=1) + + +class UserQuestionAnswerItem(BaseModel): + """Answer to one original question id, using selected labels or custom text.""" + + id: str + selected: list[str] + custom: str | None = None + + +class UserQuestionAnswer(BaseModel): + """One answer per original question; an empty batch is not an implicit approval.""" + + answers: list[UserQuestionAnswerItem] diff --git a/python/sdk/tests/test_user_questions.py b/python/sdk/tests/test_user_questions.py new file mode 100644 index 0000000000..158d78cc87 --- /dev/null +++ b/python/sdk/tests/test_user_questions.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import sys +import threading +from pathlib import Path + +import pytest +from pydantic import BaseModel + +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, UserQuestionRequest +from deepseek_harness.errors import TransportClosedError + + +class Response(BaseModel): + answer: dict | None = None + error: dict | None = None + cancelled: bool = False + replies: int = 0 + capabilities: dict | None = None + + +@pytest.fixture +def runtime(tmp_path: Path) -> Path: + path = tmp_path / "questions.py" + path.write_text(''' +import json +import sys + +pending = {} +replies = 0 +capabilities = None +serial = 0 + +def send(frame): + print(json.dumps({'jsonrpc': '2.0', **frame}), flush=True) + +for line in sys.stdin: + frame = json.loads(line) + method = frame.get('method') + params = frame.get('params') or {} + if method is None: + replies += 1 + original = pending.pop(frame['id'], None) + if original is not None: + send({'id': original, 'result': {'answer': frame.get('result'), 'error': frame.get('error')}}) + elif method == 'initialize': + capabilities = params.get('capabilities') + send({'id': frame['id'], 'result': {'serverInfo': {'name': 'question-peer', 'version': '1'}}}) + elif method == 'ask': + serial += 1 + wire_id = 'host-' + str(serial) + pending[wire_id] = frame['id'] + send({'id': wire_id, 'method': 'user-question/ask', 'params': { + 'requestId': params.get('requestId', wire_id), 'sessionId': 'main', + 'questions': params.get('questions', [{'id': 'format', 'question': 'Which format?', 'detail': 'Plan to review', + 'options': [{'label': 'CSV'}], 'intent': {'kind': 'plan-review', 'approve': 'CSV'}}])}}) + elif method == 'cancel': + send({'method': 'user-question.cancelled', 'params': {'requestId': params['requestId'], 'sessionId': 'main'}}) + for original in pending.values(): + send({'id': original, 'result': {'cancelled': True}}) + pending.clear() + send({'id': frame['id'], 'result': {}}) + elif method == 'disconnect': + break + elif method == 'stats': + send({'id': frame['id'], 'result': {'replies': replies, 'capabilities': capabilities}}) + elif method == 'shutdown': + send({'id': frame['id'], 'result': {}}) + break +''') + return path + + +def config(callback=None) -> HarnessConfig: + return HarnessConfig(on_user_question=callback, request_timeout_seconds=5) + + +def initialize(client: HarnessClient, root: Path) -> None: + client.initialize(cwd=str(root), provider="mock", model="mock") + + +def test_callback_preserves_question_metadata_and_answers_without_blocking_reader(runtime: Path, tmp_path: Path) -> None: + def answer(request: UserQuestionRequest, cancelled: threading.Event): + assert request.session_id == "main" + assert request.questions[0]["intent"] == {"kind": "plan-review", "approve": "CSV"} + assert request.questions[0]["detail"] == "Plan to review" + assert not cancelled.is_set() + # Reentrant RPC proves the reader keeps processing while the host decides. + assert client.request("stats", {}, response_model=Response).capabilities == {"userQuestions": True} + return {"answers": [{"id": "format", "selected": ["CSV"]}]} + + with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client: + initialize(client, tmp_path) + result = client.request("ask", {}, response_model=Response) + assert result.answer == {"answers": [{"id": "format", "selected": ["CSV"]}]} + assert result.error is None + + +def test_missing_callback_does_not_advertise_question_support(runtime: Path, tmp_path: Path) -> None: + with HarnessClient(config(), _launch_args=(sys.executable, str(runtime))) as client: + initialize(client, tmp_path) + assert client.request("stats", {}, response_model=Response).capabilities is None + + +@pytest.mark.parametrize("action", ["cancel", "disconnect", "close"]) +def test_cancellation_stops_cooperative_host_work_and_suppresses_late_answer(runtime: Path, tmp_path: Path, action: str) -> None: + entered = threading.Event() + stopped = threading.Event() + errors: list[BaseException] = [] + + def answer(request: UserQuestionRequest, cancelled: threading.Event): + entered.set() + assert cancelled.wait(5), "callback did not receive cancellation" + stopped.set() + return {"answers": [{"id": "format", "selected": ["late"]}]} + + with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client: + initialize(client, tmp_path) + + def ask(): + try: + client.request("ask", {"requestId": "pending"}, response_model=Response) + except BaseException as error: + errors.append(error) + + caller = threading.Thread(target=ask, daemon=True) + caller.start() + try: + assert entered.wait(5) + with client._lock: + workers = [thread for _cancelled, thread in client._question_tasks.values()] + if action == "close": + client.close() + else: + try: + client.request(action, {"requestId": "pending"}, response_model=Response) + except TransportClosedError: + assert action == "disconnect" + assert stopped.wait(5) + for worker in workers: + worker.join(5) + assert not worker.is_alive() + caller.join(5) + assert not caller.is_alive() + if action == "cancel": + assert client.request("stats", {}, response_model=Response).replies == 0 + assert errors == [] + else: + assert len(errors) == 1 and isinstance(errors[0], TransportClosedError) + finally: + client.close() + caller.join(5) + + +def test_callback_failure_is_returned_to_runtime_and_reader_remains_usable(runtime: Path, tmp_path: Path) -> None: + def answer(_request: UserQuestionRequest, _cancelled: threading.Event): + raise ValueError("answer service unavailable") + + with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client: + initialize(client, tmp_path) + result = client.request("ask", {}, response_model=Response) + assert result.error == {"code": -32603, "message": "answer service unavailable"} + assert client.request("stats", {}, response_model=Response).replies == 1 + + +def test_high_level_config_installs_the_same_native_callback(runtime: Path, tmp_path: Path) -> None: + with DeepSeekHarness( + cwd=str(tmp_path), _launch_args=(sys.executable, str(runtime)), + on_user_question=lambda _request, _cancelled: {"answers": [{"id": "format", "selected": [], "custom": "JSON"}]}, + ) as harness: + result = harness.client.request("ask", {}, response_model=Response) + assert result.answer == {"answers": [{"id": "format", "selected": [], "custom": "JSON"}]} + + +def test_late_callback_cannot_answer_or_remove_a_new_process_question(runtime: Path, tmp_path: Path) -> None: + old_entered = threading.Event() + release_old = threading.Event() + new_entered = threading.Event() + stopped = threading.Event() + + def old_answer(_request, cancelled): + old_entered.set() + assert release_old.wait(5) + assert cancelled.is_set() + return {"answers": [{"id": "format", "selected": ["old"]}]} + + def new_answer(_request, cancelled): + new_entered.set() + assert cancelled.wait(5) + stopped.set() + return {"answers": [{"id": "format", "selected": ["new"]}]} + + failures = [] + settings = config(old_answer) + with HarnessClient(settings, _launch_args=(sys.executable, str(runtime))) as client: + initialize(client, tmp_path) + + def ask(): + try: + client.request("ask", {"requestId": "reused"}, response_model=Response) + except TransportClosedError as error: + failures.append(error) + + old_caller = threading.Thread(target=ask, daemon=True) + new_caller = threading.Thread(target=ask, daemon=True) + old_caller.start() + try: + assert old_entered.wait(5) + with client._lock: + old_worker = client._question_tasks["reused"][1] + client.close() + old_caller.join(5) + assert not old_caller.is_alive() + settings.on_user_question = new_answer + client.start() + initialize(client, tmp_path) + new_caller.start() + assert new_entered.wait(5) + release_old.set() + old_worker.join(5) + assert not old_worker.is_alive() + assert client.request("stats", {}, response_model=Response).replies == 0 + client.request("cancel", {"requestId": "reused"}, response_model=Response) + assert stopped.wait(5) + new_caller.join(5) + assert not new_caller.is_alive() + assert len(failures) == 1 + finally: + release_old.set() + client.close() + old_caller.join(5) + if new_caller.ident is not None: + new_caller.join(5) diff --git a/snapshots/sdk/desktop-context/session.v4.jsonl b/snapshots/sdk/desktop-context/session.v4.jsonl index 532d6245eb..f45621ef17 100644 --- a/snapshots/sdk/desktop-context/session.v4.jsonl +++ b/snapshots/sdk/desktop-context/session.v4.jsonl @@ -8,7 +8,7 @@ {"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 with exactly: SDK snapshot OK"}],"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.\n\nCurrent DeepSeek Harness implementation checkout: not provided for this SDK session.\n\nCurrent DeepSeek Harness Web GUI URL: not provided for this SDK session."}],"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."},{"name":"harness:source","text":"Current DeepSeek Harness implementation checkout: not provided for this SDK session."},{"name":"app:web-surface","text":"Current DeepSeek Harness Web GUI URL: not provided for this SDK session."}]},"role":"user","id":"{{message:3}}"},"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.\n\nCurrent DeepSeek Harness implementation checkout: \"{{sourceRoot}}\".\n\nCurrent DeepSeek Harness Web GUI URL: not available."}],"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."},{"name":"harness:source","text":"Current DeepSeek Harness implementation checkout: \"{{sourceRoot}}\"."},{"name":"app:web-surface","text":"Current DeepSeek Harness Web GUI URL: not available."}]},"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"}} diff --git a/snapshots/sdk/desktop-context/system-prompt.expected.md b/snapshots/sdk/desktop-context/system-prompt.expected.md index 933709e4fb..dca10a6a64 100644 --- a/snapshots/sdk/desktop-context/system-prompt.expected.md +++ b/snapshots/sdk/desktop-context/system-prompt.expected.md @@ -28,8 +28,8 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Start independent delegations with `subagent` or `subagent_fork` together in one assistant message and continue useful work while they run. -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/sdk/desktop-ptc-context/session.v4.jsonl b/snapshots/sdk/desktop-ptc-context/session.v4.jsonl index 532d6245eb..f45621ef17 100644 --- a/snapshots/sdk/desktop-ptc-context/session.v4.jsonl +++ b/snapshots/sdk/desktop-ptc-context/session.v4.jsonl @@ -8,7 +8,7 @@ {"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 with exactly: SDK snapshot OK"}],"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.\n\nCurrent DeepSeek Harness implementation checkout: not provided for this SDK session.\n\nCurrent DeepSeek Harness Web GUI URL: not provided for this SDK session."}],"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."},{"name":"harness:source","text":"Current DeepSeek Harness implementation checkout: not provided for this SDK session."},{"name":"app:web-surface","text":"Current DeepSeek Harness Web GUI URL: not provided for this SDK session."}]},"role":"user","id":"{{message:3}}"},"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.\n\nCurrent DeepSeek Harness implementation checkout: \"{{sourceRoot}}\".\n\nCurrent DeepSeek Harness Web GUI URL: not available."}],"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."},{"name":"harness:source","text":"Current DeepSeek Harness implementation checkout: \"{{sourceRoot}}\"."},{"name":"app:web-surface","text":"Current DeepSeek Harness Web GUI URL: not available."}]},"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"}} diff --git a/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md b/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md index 9ac32831ee..507a618543 100644 --- a/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md +++ b/snapshots/sdk/desktop-ptc-context/system-prompt.expected.md @@ -969,8 +969,8 @@ declare const tools: { } ``` -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index bd6d6e3e42..9c9b194fb5 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -130,8 +130,8 @@ const SDK_ASSERTIONS: Readonly> = { 'desktop-context': { expectedFinalResponse: 'SDK snapshot OK', runtimeContext: { - includes: ['Current DeepSeek Harness implementation checkout: not provided for this SDK session.', - 'Current DeepSeek Harness Web GUI URL: not provided for this SDK session.'], + includes: ['Current DeepSeek Harness implementation checkout: "', + 'Current DeepSeek Harness Web GUI URL: not available.'], excludes: ['/Users/'], }, }, @@ -352,6 +352,29 @@ function assembledRuntimeContexts(log: PersistedLog): string[] { }) } +/** Tokenize only the runtime checkout fact; preserve missing values and ordinary user/tool text. */ +function normalizeHarnessCheckout(content: string): string { + return content.split('\n').map((line) => { + if (line.trim() === '') return line + const event = JSON.parse(line) as { + type?: string + data?: { source?: { kind?: string; sections?: { name: string; text: string }[] }; content?: { type: string; text?: string }[] } + } + if (event.type !== 'user/message' || event.data?.source?.kind !== 'runtime-context') return line + const normalize = (text: string) => text.replace( + /Current DeepSeek Harness implementation checkout: "(?:[^"\\]|\\.)*"\./gu, + 'Current DeepSeek Harness implementation checkout: "{{sourceRoot}}".', + ) + for (const block of event.data.content ?? []) { + if (block.type === 'text' && block.text !== undefined) block.text = normalize(block.text) + } + for (const section of event.data.source.sections ?? []) { + if (section.name === 'harness:source') section.text = normalize(section.text) + } + return JSON.stringify(event) + }).join('\n') +} + function contextOf(logs: readonly { content: string; header: Record }[], cwd: string): NormalizeContext { return { sessionIds: logs.flatMap(log => typeof log.header.id === 'string' ? [log.header.id] : []), @@ -980,7 +1003,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(writerFiles, 'native writer oracle inventory').toEqual(retained ? files.map((_, index) => writerSnapshotName(index)).sort() : []) } - const { results, notifications, observedMethods, logs, initialWorkspace, finalWorkspace, cwd } = await runScenario(scenario) + const { results, notifications, observedMethods, logs: capturedLogs, initialWorkspace, finalWorkspace, cwd } = await runScenario(scenario) + const logs = capturedLogs.map(log => ({ ...log, content: normalizeHarnessCheckout(log.content) })) const ordered = orderLogs( logs, recording ? logs.length : files.length, diff --git a/snapshots/web/cordis-tool-round/system-prompt.expected.md b/snapshots/web/cordis-tool-round/system-prompt.expected.md index 26358da734..f2c0c54562 100644 --- a/snapshots/web/cordis-tool-round/system-prompt.expected.md +++ b/snapshots/web/cordis-tool-round/system-prompt.expected.md @@ -138,6 +138,6 @@ Use subagent_fork in the background by default. Start independent delegations to When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index 933709e4fb..dca10a6a64 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -28,8 +28,8 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Start independent delegations with `subagent` or `subagent_fork` together in one assistant message and continue useful work while they run. -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/fresh-round-trip/web-context.expected.md b/snapshots/web/fresh-round-trip/web-context.expected.md index e25503ce19..49623ad7ea 100644 --- a/snapshots/web/fresh-round-trip/web-context.expected.md +++ b/snapshots/web/fresh-round-trip/web-context.expected.md @@ -1,3 +1,3 @@ -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/official-subagent-bundle/system-prompt.expected.md b/snapshots/web/official-subagent-bundle/system-prompt.expected.md index d0b8994c96..4f2c90ef07 100644 --- a/snapshots/web/official-subagent-bundle/system-prompt.expected.md +++ b/snapshots/web/official-subagent-bundle/system-prompt.expected.md @@ -941,8 +941,8 @@ declare const tools: { } ``` -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/optional-string-editor/system-prompt.expected.md b/snapshots/web/optional-string-editor/system-prompt.expected.md index 6dca92d4a8..d43f7cf591 100644 --- a/snapshots/web/optional-string-editor/system-prompt.expected.md +++ b/snapshots/web/optional-string-editor/system-prompt.expected.md @@ -946,8 +946,8 @@ declare const tools: { } ``` -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index 9645eaada4..1c697baa26 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -928,8 +928,8 @@ declare const tools: { } ``` -Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. diff --git a/snapshots/web/schedule-catalog/system-prompt.expected.md b/snapshots/web/schedule-catalog/system-prompt.expected.md index 047836d88b..591a966289 100644 --- a/snapshots/web/schedule-catalog/system-prompt.expected.md +++ b/snapshots/web/schedule-catalog/system-prompt.expected.md @@ -1,8 +1,8 @@ You are an AI agent powered by DeepSeek Harness. -The runtime context names the DeepSeek Harness implementation checkout. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. +The runtime context states whether a DeepSeek Harness implementation checkout is available and names its path when provided. The checkout location and current working directory are separate values and may differ; never infer the working directory from the checkout path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. +The runtime context states whether a DeepSeek Harness Web GUI is available and names its URL when provided. When a Web GUI is provided and the user refers to "this page", "this GUI", or "this app" without naming another target, they mean that GUI. The browser provides no implicit DOM, route, or screenshot context. Client-plugin changes reload without a refresh only when the browser HMR receiver is active and `pnpm run dev:web` is running from this same checkout to rebuild their bundles; verify both before promising automatic updates. Server plugin/configuration HMR is independent of the browser receiver and the build watcher, and can be enabled in a headless runtime. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.