mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge pull request #6523 from deepseek-harness/feat/sdk-approval-rpc
SDK 支持将内部审批交由 stdio 客户端处理
This commit is contained in:
commit
4fbe6e3f42
44 files changed
+688
-37
No files matched your search
@@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
|
||||
|
||||
@@ -1446,6 +1446,7 @@ flowchart TD
|
||||
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_approval
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
@@ -1804,5 +1805,5 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`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-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -1448,6 +1448,7 @@ flowchart TD
|
||||
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_approval
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
@@ -1806,5 +1807,5 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`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-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-sdk-client/use-this-package:
|
||||
en: ff421f12a5db28a4
|
||||
zh: 2ca9b71a3458b5e0
|
||||
en: 6c37c21a2e53376b
|
||||
zh: e707d3e81c1242bd
|
||||
/deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness:
|
||||
en: 01c58dddd7e62de2
|
||||
zh: 6dbd46e244f3f135
|
||||
|
||||
@@ -25,12 +25,14 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Use this client when TypeScript code must drive a complete Harness runtime from another process and you can name the runtime executable explicitly. The common path is minimal: construct a `DeepSeekHarness` with a launch spec, run prompts, and close it so the child process is always reaped.
|
||||
|
||||
For client-owned approvals, call `harness.client.start()`, install `harness.client.onRequest(handler)`, then call `harness.start()` with `approvalRequests: true` in the Harness options. The handler receives `approval/request` parameters and returns `{ outcome: "allowed-once" | "rejected" | "cancelled" }`; a rejected handler returns a JSON-RPC error and the runtime fails that approval closed. A replacement client needs its own handler; a closed client cannot restart. Native user questions keep their `onUserQuestion` callback. Enabling approvals without the additional-request handler returns an unknown-request error and fails approvals closed as `unavailable`. Without the opt-in, existing approval answerers remain in control. See the [wire protocol](../protocol/README.md) for request fields and cancellation.
|
||||
|
||||
`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. Shell, browser, and other external processes are not restored by this API.
|
||||
|
||||
`HarnessSession.getWorkingDirectory()` reads the effective directory; `setWorkingDirectory(path)` changes it and returns the validated absolute path. Either method creates an unknown Session without starting a model turn. Relative paths use the Session's current directory. Origin metadata, permissions, and existing processes remain unchanged. `HarnessClient` exposes the same methods with a leading `sessionId` argument.
|
||||
|
||||
Use this client when TypeScript code must drive a complete Harness runtime from another process and you can name the runtime executable explicitly. The common path is minimal: construct a `DeepSeekHarness` with a launch spec, run prompts, and close it so the child process is always reaped.
|
||||
|
||||
### Running agent turns with DeepSeekHarness
|
||||
|
||||
```ts
|
||||
|
||||
@@ -25,12 +25,14 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当 TypeScript 代码需要从另一进程驱动完整 Harness 运行时、且你能显式指名运行时可执行文件时,使用本客户端。常用路径极简:用启动规格构造 `DeepSeekHarness`,运行提示词,然后关闭它,使子进程总能被回收。
|
||||
|
||||
若由客户端处理审批,先调用 `harness.client.start()`,安装 `harness.client.onRequest(handler)`,再调用 `harness.start()`,并在 Harness 选项中设置 `approvalRequests: true`。处理器接收 `approval/request` 参数,返回 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`;处理器拒绝时返回 JSON-RPC 错误,运行时拒绝放行该审批。替换客户端时需要为新实例安装处理器;已关闭的客户端不能重启。原生问答仍由 `onUserQuestion` 回调处理。启用审批但未安装附加请求处理器时,服务端请求收到未知请求错误,审批以 `unavailable` 拒绝放行。未启用时保留现有审批应答链。请求字段与取消规则见[协议](../protocol/README.zh.md)。
|
||||
|
||||
`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。
|
||||
|
||||
`HarnessSession.getWorkingDirectory()` 读取有效目录;`setWorkingDirectory(path)` 修改目录并返回校验后的绝对路径。两个方法都可创建未知 Session,而不启动模型轮次。相对路径以 Session 当前目录解析。起始目录元数据、权限与已有进程保持不变。`HarnessClient` 提供同名方法,并以 `sessionId` 作为首个参数。
|
||||
|
||||
当 TypeScript 代码需要从另一进程驱动完整 Harness 运行时、且你能显式指名运行时可执行文件时,使用本客户端。常用路径极简:用启动规格构造 `DeepSeekHarness`,运行提示词,然后关闭它,使子进程总能被回收。
|
||||
|
||||
### 用 DeepSeekHarness 运行 agent 轮次
|
||||
|
||||
```ts
|
||||
|
||||
@@ -27,6 +27,7 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
private readonly model: string
|
||||
private readonly reasoningEffort: DeepSeekHarnessOptions['reasoningEffort']
|
||||
private readonly maxTokens: number | undefined
|
||||
private readonly approvalRequests: boolean | undefined
|
||||
private initialized: Promise<void> | undefined
|
||||
private closed = false
|
||||
|
||||
@@ -43,6 +44,7 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
this.model = options.model ?? 'deepseek-v4-flash'
|
||||
this.reasoningEffort = options.reasoningEffort
|
||||
this.maxTokens = options.maxTokens
|
||||
this.approvalRequests = options.approvalRequests
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +78,7 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
model: this.model,
|
||||
...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort },
|
||||
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
|
||||
...this.approvalRequests === undefined ? {} : { approvalRequests: this.approvalRequests },
|
||||
})
|
||||
} catch (error) {
|
||||
this.initialized = undefined
|
||||
|
||||
@@ -199,6 +199,7 @@ export class HarnessClient {
|
||||
private closeTask: Promise<void> | undefined
|
||||
private readonly userQuestions = new Map<string, AbortController>()
|
||||
private userQuestionHandler: HarnessClientOptions['onUserQuestion']
|
||||
private requestHandler: ((method: string, params: Record<string, unknown>) => Promise<unknown>) | undefined
|
||||
|
||||
/** @param options - dsh profile, patch, home, process, environment, and timeout options. */
|
||||
constructor(options?: HarnessClientOptions)
|
||||
@@ -268,7 +269,10 @@ export class HarnessClient {
|
||||
})
|
||||
const transport = new JsonRpcLineTransport(child.stdout, child.stdin)
|
||||
transport.onRequest(async (method, params) => {
|
||||
if (method !== 'user-question/ask') throw new SdkProtocolError(`unknown runtime request: ${method}`)
|
||||
if (method !== 'user-question/ask') {
|
||||
if (this.requestHandler !== undefined) return this.requestHandler(method, params)
|
||||
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)) {
|
||||
@@ -304,6 +308,21 @@ export class HarnessClient {
|
||||
this.transport = transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the additional-request handler; native questions retain their onUserQuestion callback.
|
||||
* @param handler - receives wire parameters and returns the JSON-RPC result; rejection sends an error.
|
||||
* @throws when the client has not started or has already closed.
|
||||
*/
|
||||
onRequest(handler: (method: string, params: Record<string, unknown>) => Promise<unknown>): void {
|
||||
if (this.closeTask !== undefined) {
|
||||
throw new TransportClosedError('DeepSeek Harness runtime client is closed; create a new client to install a request handler')
|
||||
}
|
||||
if (this.transport === undefined) {
|
||||
throw new TransportClosedError('start the SDK client before installing a request handler')
|
||||
}
|
||||
this.requestHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the runtime if needed, then perform the process-wide handshake.
|
||||
* @param params - workspace cwd plus the provider/model route.
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface DeepSeekHarnessOptions extends HarnessClientOptions {
|
||||
reasoningEffort?: ReasoningEffortId
|
||||
/** Maximum output tokens for each conversation-model request. */
|
||||
maxTokens?: number
|
||||
/** Enable server approval requests; install client.onRequest() after client.start() and before harness.start(). */
|
||||
approvalRequests?: boolean
|
||||
}
|
||||
|
||||
/** One owned session activity interval, from enqueue receipt through idle. */
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
* - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe).
|
||||
* - `FAKE_STDERR`: write this line to stderr at boot (diagnostics-tail probe).
|
||||
* - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe).
|
||||
* - `FAKE_APPROVAL`: send one server approval request after initialization and echo its response as a notification.
|
||||
* - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe).
|
||||
*/
|
||||
|
||||
@@ -245,7 +246,17 @@ const reader = createInterface({ input: process.stdin })
|
||||
const settledSessions = new Set<string>()
|
||||
reader.on('line', (line) => {
|
||||
if (line.trim().length === 0) return
|
||||
const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record<string, unknown> }
|
||||
const frame = JSON.parse(line) as {
|
||||
id?: string | number
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
result?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
if (frame.id === 'approval-client-test') {
|
||||
notify('approval/response', { result: frame.result, error: frame.error })
|
||||
return
|
||||
}
|
||||
if (frame.method === undefined || frame.id === undefined) return
|
||||
const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
|
||||
switch (frame.method) {
|
||||
@@ -282,6 +293,7 @@ reader.on('line', (line) => {
|
||||
return
|
||||
}
|
||||
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
|
||||
if (env.FAKE_APPROVAL !== undefined) write({ jsonrpc: '2.0', id: 'approval-client-test', method: 'approval/request', params: { sessionId: 'main', toolName: 'bash' } })
|
||||
return
|
||||
case 'session/resume':
|
||||
if (env.FAKE_RESUME_ERROR !== undefined) {
|
||||
|
||||
@@ -299,6 +299,7 @@ describe('DeepSeekHarness', () => {
|
||||
model: 'custom-model',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
maxTokens: 4096,
|
||||
approvalRequests: true,
|
||||
})
|
||||
cleanups.push(() => harness.close())
|
||||
await harness.run('one')
|
||||
@@ -311,6 +312,7 @@ describe('DeepSeekHarness', () => {
|
||||
model: 'custom-model',
|
||||
reasoningEffort: 'max',
|
||||
maxTokens: 4096,
|
||||
approvalRequests: true,
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -765,3 +767,32 @@ describe('pure helpers', () => {
|
||||
])).toBe('ab')
|
||||
})
|
||||
})
|
||||
|
||||
describe('server-to-client SDK requests', () => {
|
||||
it('requires a started client and returns client decisions over the wire', async () => {
|
||||
const client = processClient(fakeLaunch({ FAKE_APPROVAL: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
expect(() => { client.onRequest(async () => ({})) }).toThrow('start the SDK client')
|
||||
client.start()
|
||||
client.onRequest(async (method, params) => {
|
||||
expect(method).toBe('approval/request')
|
||||
expect(params).toEqual({ sessionId: 'main', toolName: 'bash' })
|
||||
return { outcome: 'rejected' }
|
||||
})
|
||||
const notifications = client.subscribe()
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'mock', model: 'mock', approvalRequests: true })
|
||||
expect(await notifications.next()).toMatchObject({ method: 'approval/response', params: { result: { outcome: 'rejected' } } })
|
||||
notifications.close()
|
||||
await client.close()
|
||||
expect(() => { client.onRequest(async () => ({})) }).toThrow('client is closed; create a new client')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SDK cancellation reason projection', () => {
|
||||
it.each(['user', 'parent', 'disposed', 'legacy'])('retains the %s cancellation cause', async (kind) => {
|
||||
const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: kind })
|
||||
const result = await harness.run('cancelled')
|
||||
expect(result.events.findLast(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind } })
|
||||
})
|
||||
})
|
||||
@@ -98,3 +98,20 @@ it.each(['close', 'disconnect'] as const)('aborts host work when the client or r
|
||||
expect(signal.aborted).toBe(true)
|
||||
await pending
|
||||
})
|
||||
|
||||
it('keeps native questions active beside the approval request handler', async () => {
|
||||
const answer = { answers: [{ id: 'format', selected: ['CSV'] }] }
|
||||
const f = await client(async () => answer)
|
||||
const approvals: string[] = []
|
||||
f.client.onRequest(async (method) => {
|
||||
approvals.push(method)
|
||||
return { outcome: 'allowed-once' }
|
||||
})
|
||||
const [question, approval] = await Promise.all([
|
||||
f.client.request('test/question', { requestId: 'native' }),
|
||||
f.client.request('test/question', { requestId: 'approval', method: 'approval/request' }),
|
||||
])
|
||||
expect(question).toEqual({ answer, error: null })
|
||||
expect(approval).toEqual({ answer: { outcome: 'allowed-once' }, error: null })
|
||||
expect(approvals).toEqual(['approval/request'])
|
||||
})
|
||||
@@ -15,14 +15,14 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-sdk-protocol/use-this-package:
|
||||
en: c4ca91b51aab452a
|
||||
zh: 1c77b1deaeb34931
|
||||
en: 4e8a8a89c1e2c0c3
|
||||
zh: 68b555e03ed73374
|
||||
/deepseek-ai-dsh-sdk-protocol/use-this-package/framing-and-transport:
|
||||
en: be53c71505c0d1b4
|
||||
zh: 6210dee577715539
|
||||
/deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods:
|
||||
en: 89ec24feb09e173c
|
||||
zh: 4072cfc63b5e25e4
|
||||
en: a11a3be122c6418e
|
||||
zh: 134659fa57d64199
|
||||
/deepseek-ai-dsh-sdk-protocol/use-this-package/native-user-questions:
|
||||
en: 9dcd999ae8c66b71
|
||||
zh: 174c5fe0cbb0a9e4
|
||||
|
||||
@@ -27,13 +27,15 @@ English | [中文](README.zh.md)
|
||||
|
||||
Use this package when you build or debug an SDK wire end — the serving plugin, a client library, or custom tooling that speaks the SDK protocol. It gives you one transport for JSON-RPC 2.0 over caller-owned byte streams and the typed shapes for every SDK method and notification.
|
||||
|
||||
`InitializeParams.approvalRequests: true` enables `approval/request` server requests, indexed by `HarnessSdkClientRequestMap`. `ApprovalRequestParams` contains `sessionId`, `toolName`, optional `callId`, and optional `reason`; `ApprovalRequestResult` is `{ outcome: "allowed-once" | "rejected" | "cancelled" }`. Omission or `false` leaves existing answerers in control. Only `allowed-once` grants one operation. Cancellation discards the pending request and late responses; shutdown and transport loss fail pending decisions closed. The client must keep reading and answering requests while waiting for an Agent to finish.
|
||||
|
||||
### Framing and transport
|
||||
|
||||
Wire one JSON-RPC 2.0 message per `\n`-terminated line over byte streams you own. A frame with both `id` and `method` is a request, `id` alone is a response, and `method` alone is a notification; malformed lines are ignored. Requests with no registered handler answer `-32601`, handler failures answer `-32603`, and error responses reject the pending request with `JsonRpcResponseError`, which preserves the wire `code` and optional `data`. `start()` attaches stream listeners and `close()` detaches them and rejects pending requests without destroying the streams.
|
||||
|
||||
### The SDK methods
|
||||
|
||||
Both wire ends share one method set: seven client-to-server requests, one server-to-client request, and five server-to-client notifications.
|
||||
Both wire ends share one method set: client-to-server requests, opt-in server-to-client requests, and server notifications.
|
||||
|
||||
| Direction | Method | Payload types |
|
||||
|---|---|---|
|
||||
@@ -44,6 +46,7 @@ Both wire ends share one method set: seven client-to-server requests, one server
|
||||
| client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` |
|
||||
| client→server | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` |
|
||||
| client→server | `shutdown` | no params → `{}` |
|
||||
| server→client | `approval/request` | `ApprovalRequestParams` → `ApprovalRequestResult` |
|
||||
| server→client | `user-question/ask` | `SdkUserQuestionRequest` → `SdkUserQuestionAnswer` |
|
||||
| server→client | `user-question.cancelled` | `SdkUserQuestionCancelled` |
|
||||
| server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) |
|
||||
@@ -51,7 +54,7 @@ Both wire ends share one method set: seven client-to-server requests, one server
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
|
||||
|
||||
`HarnessSdkRequestMap`, `HarnessSdkHostRequestMap`, and `HarnessSdkNotificationMap` index these shapes by method name; the package root exports them together with the transport.
|
||||
`HarnessSdkRequestMap`, `HarnessSdkClientRequestMap`, `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.
|
||||
|
||||
|
||||
@@ -27,13 +27,15 @@ kind: "package-library"
|
||||
|
||||
当你构建或调试 SDK 协议端——服务插件、客户端库或使用该协议的自定义工具——时使用本包。它为你提供一个在调用方持有的字节流上承载 JSON-RPC 2.0 的传输,以及每个 SDK 方法与通知的类型化结构。
|
||||
|
||||
`InitializeParams.approvalRequests: true` 启用由 `HarnessSdkClientRequestMap` 索引的服务端请求 `approval/request`。`ApprovalRequestParams` 包含 `sessionId`、`toolName`,以及可选的 `callId` 和 `reason`;`ApprovalRequestResult` 为 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`。省略或设为 `false` 时保留现有应答链。只有 `allowed-once` 批准单次操作。取消会丢弃待处理请求及迟到响应;shutdown 和传输断开使待处理决定拒绝放行。客户端等待 Agent 完成期间,必须继续读取并回答请求。
|
||||
|
||||
### 分帧与传输
|
||||
|
||||
在你拥有的字节流上,每个 `\n` 结尾的行承载一条 JSON-RPC 2.0 消息。同时带 `id` 与 `method` 的帧是请求,仅 `id` 是响应,仅 `method` 是通知;格式错误的行会被忽略。没有注册处理器的请求应答 `-32601`,处理器失败应答 `-32603`,错误响应会以 `JsonRpcResponseError` 拒绝挂起的请求,并保留协议中的 `code` 与可选 `data`。`start()` 挂接流监听器,`close()` 移除监听器并拒绝挂起请求,但不销毁流。
|
||||
|
||||
### SDK 方法
|
||||
|
||||
两个协议端共享同一套方法:七个客户端到服务端请求、一个服务端到客户端请求与五个服务端到客户端通知。
|
||||
两个协议端共享同一套方法:客户端到服务端请求、显式启用的服务端到客户端请求,以及服务端通知。
|
||||
|
||||
| 方向 | 方法 | 载荷类型 |
|
||||
|---|---|---|
|
||||
@@ -44,6 +46,7 @@ kind: "package-library"
|
||||
| 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` |
|
||||
| 客户端→服务端 | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` |
|
||||
| client→server | `shutdown` | 无参数 → `{}` |
|
||||
| server→client | `approval/request` | `ApprovalRequestParams` → `ApprovalRequestResult` |
|
||||
| server→client | `user-question/ask` | `SdkUserQuestionRequest` → `SdkUserQuestionAnswer` |
|
||||
| server→client | `user-question.cancelled` | `SdkUserQuestionCancelled` |
|
||||
| server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) |
|
||||
@@ -51,7 +54,7 @@ kind: "package-library"
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
|
||||
|
||||
`HarnessSdkRequestMap`、`HarnessSdkHostRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。
|
||||
`HarnessSdkRequestMap`、`HarnessSdkClientRequestMap`、`HarnessSdkHostRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些结构;包根与传输一起导出它们。
|
||||
|
||||
`session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
|
||||
export type { JsonRpcTransportPeer } from './transport.ts'
|
||||
export type {
|
||||
ApprovalRequestParams,
|
||||
ApprovalRequestResult,
|
||||
HarnessSdkClientRequestMap,
|
||||
HarnessSdkNotificationMap,
|
||||
HarnessSdkRequestMap,
|
||||
HarnessSdkHostRequestMap,
|
||||
|
||||
@@ -36,7 +36,7 @@ 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.
|
||||
* @param signal - optional cancellation; removes the pending waiter, rejects on abort, 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.
|
||||
*/
|
||||
@@ -57,11 +57,14 @@ interface PendingRequest {
|
||||
/**
|
||||
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
|
||||
* listeners; {@link close} detaches them and rejects pending requests without
|
||||
* destroying the streams. Missing request handlers return `-32601`; handler
|
||||
* destroying the streams. Closure, input end, and input errors permanently reject
|
||||
* later outbound requests with the first failure; start does not reset that failure.
|
||||
* Missing request handlers return `-32601`; handler
|
||||
* failures return `-32603`. Notifications without a handler are dropped.
|
||||
*/
|
||||
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
private buffer = ''
|
||||
private failure: Error | undefined
|
||||
private readonly decoder = new StringDecoder('utf8')
|
||||
private started = false
|
||||
private requestHandler: RequestHandler | undefined
|
||||
@@ -83,7 +86,8 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach listeners and reject pending requests. Safe before {@link start}.
|
||||
* Detach listeners and reject pending and future outbound requests. Safe before
|
||||
* {@link start}; starting again does not reset the request failure.
|
||||
*/
|
||||
close(): void {
|
||||
this.input.off('data', this.onData)
|
||||
@@ -120,6 +124,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
* @returns the result; rejects per {@link JsonRpcTransportPeer.request}.
|
||||
*/
|
||||
request(method: string, params: object, signal?: AbortSignal): Promise<unknown> {
|
||||
if (this.failure !== undefined) return Promise.reject(this.failure)
|
||||
const id = `req_${randomUUID().replaceAll('-', '')}`
|
||||
const message = { jsonrpc: '2.0', id, method, params }
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -263,6 +268,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
}
|
||||
|
||||
private failPending(error: Error): void {
|
||||
this.failure ??= error
|
||||
const pending = [...this.pending.values()]
|
||||
this.pending.clear()
|
||||
for (const waiter of pending) waiter.reject(error)
|
||||
|
||||
@@ -17,6 +17,8 @@ import type { AskUserQuestionAnswer, AskUserQuestionItem } from '@deepseek-ai/ds
|
||||
export interface InitializeParams {
|
||||
/** Working directory recorded on every SDK-created session's header. */
|
||||
cwd: string
|
||||
/** Enable server-to-client approval/request calls; omission leaves existing answerers in control. */
|
||||
approvalRequests?: boolean
|
||||
/** Provider route every SDK-created agent runs on. */
|
||||
provider: string
|
||||
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkJsonRpcServer.initialize`). */
|
||||
@@ -181,3 +183,26 @@ export interface HarnessSdkRequestMap {
|
||||
'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult }
|
||||
'shutdown': { params: undefined; result: Record<string, never> }
|
||||
}
|
||||
|
||||
/** Internal approval projected to the SDK client without live runtime objects. */
|
||||
export interface ApprovalRequestParams {
|
||||
/** Session owning the operation, including an in-process SDK descendant. */
|
||||
sessionId: string
|
||||
/** Tool whose operation needs approval. */
|
||||
toolName: string
|
||||
/** Exact tool call, when the asker supplied one. */
|
||||
callId?: string
|
||||
/** Asker-supplied explanation, when available. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** One-shot client decision; grants never persist beyond this operation. */
|
||||
export interface ApprovalRequestResult {
|
||||
/** Only allowed-once permits the operation. */
|
||||
outcome: 'allowed-once' | 'rejected' | 'cancelled'
|
||||
}
|
||||
|
||||
/** Requests sent by the server after the client explicitly enables them. */
|
||||
export interface HarnessSdkClientRequestMap {
|
||||
'approval/request': { params: ApprovalRequestParams; result: ApprovalRequestResult }
|
||||
}
|
||||
@@ -235,6 +235,7 @@ describe('JsonRpcLineTransport', () => {
|
||||
aToB.end()
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC input closed')
|
||||
await expect(b.request('after-eof', {})).rejects.toThrow('JSON-RPC input closed')
|
||||
b.close()
|
||||
})
|
||||
|
||||
@@ -246,6 +247,7 @@ describe('JsonRpcLineTransport', () => {
|
||||
aToB.emit('error', new Error('input broke'))
|
||||
|
||||
await expect(pending).rejects.toThrow('input broke')
|
||||
await expect(b.request('after-error', {})).rejects.toThrow('input broke')
|
||||
b.close()
|
||||
})
|
||||
|
||||
@@ -256,6 +258,7 @@ describe('JsonRpcLineTransport', () => {
|
||||
b.close()
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
|
||||
await expect(b.request('after-close', {})).rejects.toThrow('JSON-RPC transport closed')
|
||||
})
|
||||
|
||||
it('rejects a request when writing the frame throws', async () => {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package:
|
||||
en: 28aab9ed02805009
|
||||
zh: dd7ac790369b7929
|
||||
en: afc4d4b529f334ba
|
||||
zh: 7c7a8d2c6854882a
|
||||
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring:
|
||||
en: 47a33e9397e36e15
|
||||
zh: e6d173820f9ea6e5
|
||||
|
||||
@@ -25,14 +25,16 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
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.
|
||||
|
||||
Set `initialize.approvalRequests` to `true` to send internal approvals as server-to-client `approval/request` calls. The bridge handles SDK-owned Agents and their live in-process descendants, following `agents.isOwnedBy()` runtime ownership rather than persisted `parentSession` lineage; unrelated Agents and clients that omit the flag continue through the composed answerer chain. The approval service still applies `never` policy before dispatch and records the ask/decision audit pair. The bridge accepts only `allowed-once`, `rejected`, or `cancelled`; an invalid response, client error, or transport loss yields the service's fail-closed `unavailable` outcome. Request cancellation abandons the pending wire waiter; shutdown abandons all such waiters before draining Agents. Late replies cannot grant an abandoned operation. See the [protocol](../protocol/README.md) for the payload and the [Python example](../../../python/sdk/README.md#client-approvals) for client handling.
|
||||
|
||||
`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 recording the initialized model route as the Session selection. An omitted effort preserves an explicit saved effort only for that same 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.
|
||||
|
||||
### Wiring
|
||||
|
||||
The plugin creates one agent per `sessionId` on first use. A registered model adapter wins the route; an unowned `deepseek-official` route mounts the DeepSeek adapter, and any other unowned provider fails initialization. The selected adapter resolves the exact model and optional reasoning effort before initialization succeeds.
|
||||
|
||||
@@ -25,14 +25,16 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。
|
||||
|
||||
将 `initialize.approvalRequests` 设为 `true`,即可把内部审批作为服务端到客户端的 `approval/request` 请求发送。桥接通过 `agents.isOwnedBy()` 运行时归属识别 SDK 拥有的 Agent 及其仍在运行的进程内后代,持久化的 `parentSession` 历史关系不决定审批归属;其他 Agent,以及未提供该标志的客户端,继续使用已组合的应答链。审批服务仍在分发前执行 `never` 策略,并记录询问与决定的审计事件。桥接仅接受 `allowed-once`、`rejected` 或 `cancelled`;无效响应、客户端错误或传输断开会产生审批服务的拒绝放行结果 `unavailable`。请求取消时释放待处理的协议等待项;shutdown 在等待 Agent 结束前释放所有此类等待项。迟到响应不能授权已放弃的操作。载荷见[协议](../protocol/README.zh.md),客户端处理见 [Python 示例](../../../python/sdk/README.zh.md#client-approvals)。
|
||||
|
||||
`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回 `{}`。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时将初始化时选定的模型路由记录为 Session 选择。省略推理强度时,仅在路由相同的情况下保留已保存的显式强度。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;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` 服务;其余每个能力都来自外围插件树。
|
||||
|
||||
### 组装
|
||||
|
||||
插件在首次使用时为每个 `sessionId` 创建一个 agent。已注册的模型适配器优先用于该路由;尚无适配器负责的 `deepseek-official` 路由会挂载 DeepSeek 适配器,任何其他尚无适配器负责的提供方都会导致初始化失败。初始化成功前,所选适配器会解析确切模型与可选推理强度。
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
"@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-user-questions": "workspace:*"
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
@@ -62,7 +63,8 @@
|
||||
"@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-user-questions": "workspace:*"
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,14 @@ 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-user-approval'
|
||||
|
||||
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'
|
||||
import type {
|
||||
ApprovalRequestParams,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
JsonRpcTransportPeer,
|
||||
@@ -95,6 +98,8 @@ export class HarnessSdkJsonRpcServer {
|
||||
private shutdownTask: Promise<Record<string, never>> | undefined
|
||||
private shuttingDown = false
|
||||
private initialized = false
|
||||
private approvalRequests = false
|
||||
private readonly lifetime = new AbortController()
|
||||
private readonly userQuestions: SdkUserQuestionBridge
|
||||
private readonly initialization = Promise.withResolvers<void>()
|
||||
/** Restored by a reminder and available for the client's first resume. */
|
||||
@@ -105,6 +110,24 @@ export class HarnessSdkJsonRpcServer {
|
||||
private readonly transport: JsonRpcTransportPeer,
|
||||
private readonly options: HarnessSdkJsonRpcServerOptions = {},
|
||||
) {
|
||||
this.disposers.push(ctx.on('approval/request', async (request, next) => {
|
||||
if (!this.approvalRequests || !this.ownsAgent(request.agent)) return next()
|
||||
const params: ApprovalRequestParams = {
|
||||
sessionId: request.agent.session.id,
|
||||
toolName: request.toolName,
|
||||
...(request.callId === undefined ? {} : { callId: request.callId }),
|
||||
...(request.reason === undefined ? {} : { reason: request.reason }),
|
||||
}
|
||||
const signal = request.signal === undefined
|
||||
? this.lifetime.signal
|
||||
: AbortSignal.any([request.signal, this.lifetime.signal])
|
||||
const result = await this.transport.request('approval/request', params, signal)
|
||||
if (result === null || typeof result !== 'object' || !('outcome' in result)
|
||||
|| (result.outcome !== 'allowed-once' && result.outcome !== 'rejected' && result.outcome !== 'cancelled')) {
|
||||
throw new TypeError('approval/request response requires an allowed-once, rejected, or cancelled outcome')
|
||||
}
|
||||
return result.outcome
|
||||
}))
|
||||
const serverOptions = this.options
|
||||
this.userQuestions = new SdkUserQuestionBridge(ctx, transport, agent =>
|
||||
this.sessions.get(agent.session.id)?.handle.agent === agent)
|
||||
@@ -181,6 +204,9 @@ export class HarnessSdkJsonRpcServer {
|
||||
* @returns server identity for the handshake.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
if (params.approvalRequests !== undefined && typeof params.approvalRequests !== 'boolean') {
|
||||
throw new TypeError('initialize approvalRequests must be a boolean')
|
||||
}
|
||||
if (params.reasoningEffort !== undefined
|
||||
&& (typeof params.reasoningEffort !== 'string' || params.reasoningEffort.length === 0)) {
|
||||
throw new TypeError('initialize reasoningEffort must be a non-empty string')
|
||||
@@ -212,6 +238,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
this.model = model
|
||||
this.reasoningEffort = reasoningEffort
|
||||
this.maxTokens = params.maxTokens
|
||||
this.approvalRequests = params.approvalRequests === true
|
||||
this.userQuestions.enabled = params.capabilities?.userQuestions === true
|
||||
this.initialized = true
|
||||
this.initialization.resolve()
|
||||
@@ -293,6 +320,20 @@ export class HarnessSdkJsonRpcServer {
|
||||
}
|
||||
}
|
||||
|
||||
private ownsAgent(agent: Agent): boolean {
|
||||
const agents = this.ctx.agents
|
||||
if (agents.get(agent.id) !== agent) return false
|
||||
const liveAgents = agents.list()
|
||||
const ancestors = new Set([agent])
|
||||
for (const current of ancestors) {
|
||||
if (this.sessions.get(current.session.id)?.handle.agent === current) return true
|
||||
for (const parent of liveAgents) {
|
||||
if (agents.isOwnedBy(current.id, parent)) ancestors.add(parent)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private assertLiveAgent(rec: SessionRecord, sessionId: string): void {
|
||||
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
|
||||
throw new Error(`session agent was disposed outside the server: ${sessionId}`)
|
||||
@@ -339,6 +380,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
|
||||
private async performShutdown(): Promise<Record<string, never>> {
|
||||
this.shuttingDown = true
|
||||
this.lifetime.abort(new Error('SDK server is shutting down'))
|
||||
this.userQuestions.close()
|
||||
this.initialization.resolve()
|
||||
const pendingCreations = [...this.sessionCreations.values()]
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/** SDK approvals use the real bidirectional wire and the internal audit service. */
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts'
|
||||
import { HarnessSdkJsonRpcServer } from '../src/server.ts'
|
||||
|
||||
async function setup(enabled?: boolean) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountWorkingDirectoryFixture(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(ApprovalService)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('done')]))
|
||||
const input = new PassThrough()
|
||||
const output = new PassThrough()
|
||||
const serverWire = new JsonRpcLineTransport(input, output)
|
||||
const client = new JsonRpcLineTransport(output, input)
|
||||
const server = new HarnessSdkJsonRpcServer(ctx, serverWire)
|
||||
onTestFinished(async () => {
|
||||
await server.shutdown()
|
||||
serverWire.close()
|
||||
client.close()
|
||||
input.destroy()
|
||||
output.destroy()
|
||||
})
|
||||
serverWire.onRequest((method, params) => server.handleRequest(method, params))
|
||||
serverWire.start()
|
||||
client.start()
|
||||
await client.request('initialize', {
|
||||
cwd: process.cwd(), provider: 'mock', model: 'mock',
|
||||
...(enabled === undefined ? {} : { approvalRequests: enabled }),
|
||||
})
|
||||
await client.request('session/working-directory/get', { sessionId: 'main' })
|
||||
const agent = ctx.agents.get(SessionId('main'))!
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
return { ctx, server, serverWire, client, agent, input }
|
||||
}
|
||||
|
||||
describe('SDK approval bridge', () => {
|
||||
it.each(['allowed-once', 'rejected', 'cancelled'] as const)('applies and audits %s', async (outcome) => {
|
||||
const h = await setup(true)
|
||||
h.client.onRequest(async (method, params) => {
|
||||
expect(method).toBe('approval/request')
|
||||
expect(params).toEqual({ sessionId: 'main', toolName: 'bash', callId: 'call-1', reason: 'write outside workspace' })
|
||||
return { outcome }
|
||||
})
|
||||
await expect(h.ctx.approval.request({
|
||||
agent: h.agent, toolName: 'bash', callId: ToolCallId('call-1'), reason: 'write outside workspace',
|
||||
})).resolves.toBe(outcome)
|
||||
expect(h.agent.session.snapshotEvents().slice(-2).map(e => [e.type, e.data])).toEqual([
|
||||
['approval/asked', expect.objectContaining({ toolName: 'bash', reason: 'write outside workspace' })],
|
||||
['approval/decided', expect.objectContaining({ outcome })],
|
||||
])
|
||||
})
|
||||
|
||||
it.each([undefined, false])('delegates when approvalRequests is %s', async (enabled) => {
|
||||
const h = await setup(enabled)
|
||||
h.ctx.on('approval/request', () => Promise.resolve('rejected'))
|
||||
await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it.each([null, {}, { outcome: 'allow-always' }, { outcome: true }])('fails closed for invalid response %j', async (result) => {
|
||||
const h = await setup(true)
|
||||
h.client.onRequest(() => Promise.resolve(result))
|
||||
await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('fails closed on client errors and a disconnected transport', async () => {
|
||||
const h = await setup(true)
|
||||
h.client.onRequest(() => Promise.reject(new Error('no decision')))
|
||||
await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
|
||||
h.serverWire.close()
|
||||
await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('correlates concurrent requests while another client request is in flight', async () => {
|
||||
const h = await setup(true)
|
||||
const first = Promise.withResolvers<object>()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
h.client.onRequest(async (_method, params) => {
|
||||
if (params.toolName === 'first') {
|
||||
entered.resolve(undefined)
|
||||
return first.promise
|
||||
}
|
||||
return { outcome: 'rejected' }
|
||||
})
|
||||
onTestFinished(() => { first.resolve({ outcome: 'cancelled' }) })
|
||||
const pending = h.ctx.approval.request({ agent: h.agent, toolName: 'first' })
|
||||
await entered.promise
|
||||
await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'second' })).resolves.toBe('rejected')
|
||||
await expect(h.client.request('session/working-directory/get', { sessionId: 'main' })).resolves.toHaveProperty('cwd')
|
||||
first.resolve({ outcome: 'allowed-once' })
|
||||
await expect(pending).resolves.toBe('allowed-once')
|
||||
})
|
||||
|
||||
it.each(['cancel', 'shutdown', 'disconnect'] as const)('settles a pending approval on %s and ignores a late grant', async (action) => {
|
||||
const h = await setup(true)
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const answer = Promise.withResolvers<object>()
|
||||
h.client.onRequest(() => { entered.resolve(undefined); return answer.promise })
|
||||
onTestFinished(() => { answer.resolve({ outcome: 'cancelled' }) })
|
||||
const cancellation = new AbortController()
|
||||
const pending = h.ctx.approval.request({ agent: h.agent, toolName: 'bash', signal: cancellation.signal })
|
||||
await entered.promise
|
||||
if (action === 'cancel') cancellation.abort()
|
||||
else if (action === 'shutdown') await h.server.shutdown()
|
||||
else h.serverWire.close()
|
||||
await expect(pending).resolves.toBe(action === 'cancel' ? 'cancelled' : 'unavailable')
|
||||
const delivered = Promise.withResolvers<Buffer>()
|
||||
h.input.once('data', delivered.resolve)
|
||||
onTestFinished(() => { h.input.off('data', delivered.resolve) })
|
||||
answer.resolve({ outcome: 'allowed-once' })
|
||||
expect(JSON.parse(String(await delivered.promise))).toMatchObject({ result: { outcome: 'allowed-once' } })
|
||||
expect(h.agent.session.snapshotEvents().filter(e => e.type === 'approval/decided').map(e => e.data.outcome))
|
||||
.toEqual([action === 'cancel' ? 'cancelled' : 'unavailable'])
|
||||
})
|
||||
|
||||
it.each([false, true])('does not claim an unrelated live Agent with SDK session lineage: %s', async (hasLineage) => {
|
||||
const h = await setup(true)
|
||||
const foreign = await h.ctx.agents.create({
|
||||
sessionId: SessionId('foreign'),
|
||||
meta: { cwd: process.cwd(), ...(hasLineage ? { parentSession: h.agent.session.id } : {}) },
|
||||
})
|
||||
onTestFinished(() => foreign.dispose())
|
||||
foreign.agent.session.append('turn/start', { turn: 1 })
|
||||
const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' }))
|
||||
h.client.onRequest(clientDecision)
|
||||
h.ctx.on('approval/request', () => Promise.resolve('rejected'))
|
||||
await expect(h.ctx.approval.request({ agent: foreign.agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
expect(clientDecision).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([false, true])('serves runtime descendants with SDK session lineage: %s and preserves never policy', async (hasLineage) => {
|
||||
const h = await setup(true)
|
||||
const child = await h.ctx.agents.create({
|
||||
sessionId: SessionId('child'), parentAgent: h.agent,
|
||||
meta: { cwd: process.cwd(), ...(hasLineage ? { parentSession: h.agent.session.id } : {}) },
|
||||
})
|
||||
onTestFinished(() => child.dispose())
|
||||
const grandchild = await h.ctx.agents.create({
|
||||
sessionId: SessionId('grandchild'), parentAgent: child.agent, meta: { cwd: process.cwd() },
|
||||
})
|
||||
onTestFinished(() => grandchild.dispose())
|
||||
child.agent.session.append('turn/start', { turn: 1 })
|
||||
grandchild.agent.session.append('turn/start', { turn: 1 })
|
||||
const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' }))
|
||||
h.client.onRequest(clientDecision)
|
||||
for (const agent of [child.agent, grandchild.agent]) {
|
||||
await expect(h.ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
|
||||
expect(clientDecision).toHaveBeenLastCalledWith('approval/request', { sessionId: agent.session.id, toolName: 'bash' })
|
||||
}
|
||||
h.ctx.approval.setPolicy(child.agent, 'never')
|
||||
await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
expect(clientDecision).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not claim another runtime owner\'s child despite SDK session lineage', async () => {
|
||||
const h = await setup(true)
|
||||
const owner = await h.ctx.agents.create({ sessionId: SessionId('foreign-owner') })
|
||||
onTestFinished(() => owner.dispose())
|
||||
const child = await h.ctx.agents.create({
|
||||
sessionId: SessionId('foreign-child'), parentAgent: owner.agent,
|
||||
meta: { parentSession: h.agent.session.id },
|
||||
})
|
||||
onTestFinished(() => child.dispose())
|
||||
child.agent.session.append('turn/start', { turn: 1 })
|
||||
const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' }))
|
||||
h.client.onRequest(clientDecision)
|
||||
h.ctx.on('approval/request', () => Promise.resolve('rejected'))
|
||||
await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
expect(clientDecision).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not claim a disposed Agent when an owned replacement reuses its id', async () => {
|
||||
const h = await setup(true)
|
||||
const old = await h.ctx.agents.create({ sessionId: SessionId('reused'), parentAgent: h.agent })
|
||||
await old.dispose()
|
||||
const replacement = await h.ctx.agents.create({ sessionId: old.agent.id, parentAgent: h.agent })
|
||||
onTestFinished(() => replacement.dispose())
|
||||
const clientDecision = vi.fn(async () => ({ outcome: 'allowed-once' }))
|
||||
h.client.onRequest(clientDecision)
|
||||
// Dispatch at the answerer seam: disposal has already closed the old Agent's turn.
|
||||
await expect(h.ctx.waterfall('approval/request', { agent: old.agent, toolName: 'bash' },
|
||||
() => Promise.resolve('rejected' as const))).resolves.toBe('rejected')
|
||||
expect(clientDecision).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid wire capability flag', async () => {
|
||||
const h = await setup()
|
||||
await expect(h.client.request('initialize', { cwd: '.', provider: 'mock', model: 'mock', approvalRequests: 'true' }))
|
||||
.rejects.toThrow('approvalRequests must be a boolean')
|
||||
})
|
||||
})
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/** Exercises the composed internal approval service. */
|
||||
import { appendFile } from 'node:fs/promises'
|
||||
|
||||
export const name = 'sdk-approval-fixture'
|
||||
export const inject = ['approval']
|
||||
|
||||
/** Ask once per turn and expose whether the guarded operation ran. */
|
||||
export function apply(ctx, config) {
|
||||
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
||||
const outcome = await ctx.approval.request({ agent, signal, toolName: 'fixture-write', reason: 'write approval marker' })
|
||||
if (outcome === 'allowed-once') await appendFile(config.path, 'approved\n')
|
||||
return config.stopAfterApproval ? { kind: 'reject' } : next()
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-questions"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+3
@@ -12898,6 +12898,9 @@ importers:
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:*
|
||||
version: link:../../subagent/subagent
|
||||
'@deepseek-ai/dsh-user-approval':
|
||||
specifier: workspace:*
|
||||
version: link:../../interaction/user-approval
|
||||
'@deepseek-ai/dsh-user-questions':
|
||||
specifier: workspace:*
|
||||
version: link:../../interaction/user-questions
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
en: ddf4dabea526cef1
|
||||
zh: 6e31cab10d2f3ed8
|
||||
/deepseek-harness-python-sdk/start-a-runtime:
|
||||
en: 5fceaffcc35039c6
|
||||
zh: 1d87e3defe7e875b
|
||||
en: 1ae09bebf0f9e19b
|
||||
zh: 55a0d557a2cae4d8
|
||||
/deepseek-harness-python-sdk/client-approvals:
|
||||
en: fc69be1beb8abff1
|
||||
zh: 91b56781931a6f68
|
||||
/deepseek-harness-python-sdk/customize-plugins:
|
||||
en: 69a6a6745c6e89c1
|
||||
zh: 85e82a4cc31e16e5
|
||||
|
||||
@@ -58,6 +58,27 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
result = session.run("Continue the task.")
|
||||
```
|
||||
|
||||
<a id="client-approvals"></a>
|
||||
## Client approvals
|
||||
|
||||
This low-level example receives one approval and rejects it. Use a dedicated request-handling thread or loop for a full run: `next_request()` blocks, and the request handler must run concurrently with `session/wait` or a high-level `run()`. Set `approval_requests=True` on `HarnessClient.initialize()` or `DeepSeekHarnessConfig` only when that handler is ready. Omission preserves the runtime's answerer chain. Decisions are one-shot; invalid responses and lost connections fail closed, and cancellation discards late replies. `ApprovalRequestResult` validates response values when constructing decisions.
|
||||
|
||||
```py
|
||||
from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig
|
||||
|
||||
with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as client:
|
||||
client.initialize(cwd="/absolute/path/to/workspace", provider="deepseek-official",
|
||||
model="deepseek-v4-flash", approval_requests=True)
|
||||
client.session_prompt("main", [{"type": "text", "text": "Run the operation requiring approval."}])
|
||||
request = client.next_request()
|
||||
if request.method == "approval/request":
|
||||
question = ApprovalRequestParams.model_validate(request.payload)
|
||||
print(question.toolName, question.reason)
|
||||
client.respond(request.id, {"outcome": "rejected"})
|
||||
else:
|
||||
client.respond_error(request.id, code=-32601, message="Unsupported client request")
|
||||
```
|
||||
|
||||
## Customize plugins
|
||||
|
||||
Persistent customization belongs to a `dsh` profile. Initialize the shipped SDK profile and install an external bundle with the runtime wheel's `dsh` command:
|
||||
|
||||
@@ -58,6 +58,27 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
result = session.run("Continue the task.")
|
||||
```
|
||||
|
||||
<a id="client-approvals"></a>
|
||||
## 客户端审批
|
||||
|
||||
此底层示例接收一次审批并拒绝。完整运行应使用专门处理请求的线程或循环:`next_request()` 会阻塞,请求处理器必须与 `session/wait` 或高层 `run()` 并行运行。仅在处理器就绪时,在 `HarnessClient.initialize()` 或 `DeepSeekHarnessConfig` 设置 `approval_requests=True`;省略时保留运行时应答链。决定只作用于单次操作;无效响应和断连会拒绝放行,取消后丢弃迟到响应。构造决定时,可用 `ApprovalRequestResult` 校验响应值。
|
||||
|
||||
```py
|
||||
from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig
|
||||
|
||||
with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as client:
|
||||
client.initialize(cwd="/absolute/path/to/workspace", provider="deepseek-official",
|
||||
model="deepseek-v4-flash", approval_requests=True)
|
||||
client.session_prompt("main", [{"type": "text", "text": "Run the operation requiring approval."}])
|
||||
request = client.next_request()
|
||||
if request.method == "approval/request":
|
||||
question = ApprovalRequestParams.model_validate(request.payload)
|
||||
print(question.toolName, question.reason)
|
||||
client.respond(request.id, {"outcome": "rejected"})
|
||||
else:
|
||||
client.respond_error(request.id, code=-32601, message="Unsupported client request")
|
||||
```
|
||||
|
||||
## 自定义插件
|
||||
|
||||
持久自定义属于 `dsh` profile。使用运行时 wheel 包提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
|
||||
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
|
||||
from .errors import SdkProtocolError
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest
|
||||
from .models import (
|
||||
ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse,
|
||||
JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeepSeekHarness",
|
||||
@@ -15,6 +18,8 @@ __all__ = [
|
||||
"UserQuestionAnswer",
|
||||
"UserQuestionAnswerItem",
|
||||
"SdkProtocolError",
|
||||
"ApprovalRequestParams",
|
||||
"ApprovalRequestResult",
|
||||
"IncomingRequest",
|
||||
"InitializeResponse",
|
||||
"JsonObject",
|
||||
|
||||
@@ -36,6 +36,7 @@ class DeepSeekHarnessConfig:
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
on_user_question: UserQuestionHandler | None = None
|
||||
approval_requests: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -112,6 +113,7 @@ class DeepSeekHarness:
|
||||
model=self.config.model,
|
||||
reasoning_effort=self.config.reasoning_effort,
|
||||
max_tokens=self.config.max_tokens,
|
||||
approval_requests=self.config.approval_requests,
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
|
||||
@@ -156,12 +156,21 @@ class HarnessClient:
|
||||
model: str,
|
||||
reasoning_effort: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
approval_requests: bool = False,
|
||||
) -> InitializeResponse:
|
||||
"""Initialize the runtime; approval_requests enables approval/request reception.
|
||||
|
||||
When enabled, drain next_request() concurrently with blocking waits and
|
||||
return one-operation decisions through respond(). Omission preserves the
|
||||
runtime's composed approval answerers.
|
||||
"""
|
||||
payload: JsonObject = {
|
||||
"cwd": str(Path(cwd).resolve()),
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
}
|
||||
if approval_requests:
|
||||
payload["approvalRequests"] = True
|
||||
if reasoning_effort is not None:
|
||||
payload["reasoningEffort"] = reasoning_effort
|
||||
if max_tokens is not None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue
|
||||
|
||||
@@ -32,6 +32,20 @@ class InitializeResponse(BaseModel):
|
||||
serverInfo: ServerInfo | None = None
|
||||
|
||||
|
||||
class ApprovalRequestParams(BaseModel):
|
||||
"""Server-to-client approval/request payload after explicit handshake opt-in."""
|
||||
|
||||
sessionId: str
|
||||
toolName: str
|
||||
callId: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ApprovalRequestResult(BaseModel):
|
||||
"""One-operation decision; allowed-once is the only grant."""
|
||||
|
||||
outcome: Literal["allowed-once", "rejected", "cancelled"]
|
||||
|
||||
class UserQuestionRequest(BaseModel):
|
||||
"""Runtime question batch; questions retain their options, detail, and presentation intent."""
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Approval request decoding and explicit wire capability negotiation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from deepseek_harness import ApprovalRequestParams, ApprovalRequestResult, DeepSeekHarnessConfig, HarnessClient
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_approval_handshake_and_response(tmp_path: Path, enabled: bool) -> None:
|
||||
peer = tmp_path / "approval_peer.py"
|
||||
peer.write_text('''import json
|
||||
import sys
|
||||
for line in sys.stdin:
|
||||
msg = json.loads(line)
|
||||
if msg.get("method") == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "test"}}}), flush=True)
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "capability", "params": msg["params"]}), flush=True)
|
||||
if msg["params"].get("approvalRequests"):
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": "ask", "method": "approval/request", "params": {"sessionId": "main", "toolName": "bash", "reason": "write"}}), flush=True)
|
||||
elif msg.get("id") == "ask":
|
||||
print(json.dumps({"jsonrpc": "2.0", "method": "decision", "params": msg["result"]}), flush=True)
|
||||
elif msg.get("method") == "shutdown":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
|
||||
break
|
||||
''')
|
||||
with HarnessClient(_launch_args=(sys.executable, str(peer))) as client:
|
||||
client.initialize(cwd=str(tmp_path), provider="mock", model="mock", approval_requests=enabled)
|
||||
payload = client.next_notification().payload
|
||||
assert payload.get("approvalRequests", False) is enabled
|
||||
if not enabled:
|
||||
assert "approvalRequests" not in payload
|
||||
return
|
||||
request = client.next_request()
|
||||
assert request.method == "approval/request"
|
||||
question = ApprovalRequestParams.model_validate(request.payload)
|
||||
assert question.toolName == "bash"
|
||||
assert question.callId is None
|
||||
client.respond(request.id, ApprovalRequestResult(outcome="rejected").model_dump())
|
||||
assert client.next_notification().payload == {"outcome": "rejected"}
|
||||
|
||||
|
||||
def test_approval_result_rejects_unrecognized_grants() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ApprovalRequestResult(outcome="allow-always")
|
||||
|
||||
|
||||
def test_approval_opt_in_preserves_existing_config_positional_arguments() -> None:
|
||||
config = DeepSeekHarnessConfig("mock", "model", None, None, "/workspace")
|
||||
assert config.cwd == "/workspace"
|
||||
assert config.approval_requests is False
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Python decisions control a guarded file operation through the built sdk profile."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig
|
||||
|
||||
|
||||
class EmptyResponse(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["allowed-once", "rejected"])
|
||||
def test_python_approval_controls_runtime_operation(tmp_path: Path, outcome: str) -> None:
|
||||
repo = Path(__file__).resolve().parents[3]
|
||||
node = shutil.which("node")
|
||||
binary = repo / "apps/cli/lib/bin.js"
|
||||
if node is None or not binary.exists():
|
||||
pytest.skip("requires Node and built dsh CLI")
|
||||
fixture = repo / "packages/sdk/server/tests/fixtures/approval.mjs"
|
||||
marker = tmp_path / "approved.txt"
|
||||
patch = tmp_path / "approval.patch.yml"
|
||||
patch.write_text("- insert:\n - id: approval-fixture\n name: " + json.dumps(str(fixture))
|
||||
+ "\n config:\n stopAfterApproval: true\n path: " + json.dumps(str(marker)) + "\n")
|
||||
# The client closes inside the executor lifetime, unblocking a reader even
|
||||
# when a regression prevents the expected request from arriving.
|
||||
with ThreadPoolExecutor(max_workers=1) as worker:
|
||||
with HarnessClient(
|
||||
HarnessConfig(cwd=str(repo), request_timeout_seconds=20, shutdown_timeout_seconds=20,
|
||||
env={"DSH_HOME": str(tmp_path / "home"), "DSH_PERMISSION_MODE": "workspace-write",
|
||||
"DSH_TELEMETRY_DISABLED": "1", "DEEPSEEK_API_KEY": "keyless-no-model-call"}),
|
||||
_launch_args=(node, str(binary), "--profile", "sdk", "--patch", str(patch)),
|
||||
) as client:
|
||||
client.initialize(cwd=str(tmp_path), provider="deepseek-official", model="deepseek-v4-flash",
|
||||
approval_requests=True)
|
||||
client.session_prompt("main", [{"type": "text", "text": "Request the marker write."}])
|
||||
request = worker.submit(client.next_request).result(timeout=20)
|
||||
assert request.method == "approval/request"
|
||||
question = ApprovalRequestParams.model_validate(request.payload)
|
||||
assert question.sessionId == "main"
|
||||
assert question.toolName == "fixture-write"
|
||||
client.respond(request.id, {"outcome": outcome})
|
||||
client.request("session/wait", {"sessionId": "main"}, response_model=EmptyResponse)
|
||||
assert marker.exists() is (outcome == "allowed-once")
|
||||
if marker.exists():
|
||||
assert marker.read_text() == "approved\n"
|
||||
@@ -165,6 +165,7 @@ for line in sys.stdin:
|
||||
)
|
||||
|
||||
with DeepSeekHarness(
|
||||
approval_requests=True,
|
||||
model="deepseek-v4-flash",
|
||||
reasoning_effort="max",
|
||||
max_tokens=4096,
|
||||
@@ -211,6 +212,7 @@ for line in sys.stdin:
|
||||
assert dumped_env["DSH_SESSION_ROOT"] is None
|
||||
assert dumped_env["DSH_CORDIS_CONFIG"] is None
|
||||
assert json.loads(init_dump.read_text()) == {
|
||||
"approvalRequests": True,
|
||||
"cwd": str(tmp_path),
|
||||
"provider": "deepseek-official",
|
||||
"model": "deepseek-v4-flash",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- insert:
|
||||
- id: sdk-approval-fixture
|
||||
name: ../../../packages/sdk/server/tests/fixtures/approval.mjs
|
||||
config:
|
||||
path: !!js "process.getBuiltinModule('node:path').join(process.cwd(), 'approved.txt')"
|
||||
@@ -0,0 +1,29 @@
|
||||
{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783352113760,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"workspace-write"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
|
||||
{"type":"approval/policy","data":{"policy":"ask"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"approval/asked","data":{"id":"{{approval:1}}","toolName":"fixture-write","reason":"write approval marker"}}
|
||||
{"type":"approval/decided","data":{"id":"{{approval:1}}","outcome":"allowed-once"}}
|
||||
{"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 the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"Reply with exactly the word:","messageSeqs":[10],"source":{"kind":"fallback"}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18},"stream":[{"type":"chunk","time":1792382979881,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1792382979881,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]},{"type":"chunk","time":1792382979881,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1792382979881,"index":1,"dt":[],"texts":["ONE"]},{"type":"chunk","time":1792382979881,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}},{"type":"chunk","time":1792382979881,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}},{"type":"chunk","time":1792382979881,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}},{"type":"chunk","time":1792382979881,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":2}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"approval/asked","data":{"id":"{{approval:2}}","toolName":"fixture-write","reason":"write approval marker"}}
|
||||
{"type":"approval/decided","data":{"id":"{{approval:2}}","outcome":"rejected"}}
|
||||
{"type":"step/start","data":{"turn":2,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18},"stream":[{"type":"chunk","time":1792382979900,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1792382979900,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","T","WO","\""," and"," no"," tools","."]},{"type":"chunk","time":1792382979900,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1792382979900,"index":1,"dt":[0],"texts":["T","WO"]},{"type":"chunk","time":1792382979900,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}},{"type":"chunk","time":1792382979900,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}},{"type":"chunk","time":1792382979900,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}},{"type":"chunk","time":1792382979900,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,9 @@
|
||||
version: 1
|
||||
scenario: approval
|
||||
profile: sdk
|
||||
composition: default
|
||||
recording: authored
|
||||
header:
|
||||
class: default
|
||||
workspace:
|
||||
final: true
|
||||
@@ -0,0 +1 @@
|
||||
approved
|
||||
@@ -101,6 +101,8 @@ function dirOf(url: string): string {
|
||||
}
|
||||
|
||||
interface SdkAssertions {
|
||||
/** Client decisions for the internal approval protocol recording. */
|
||||
approvalOutcomes?: readonly ('allowed-once' | 'rejected')[]
|
||||
/** Additional profile patches applied after the shared composition. */
|
||||
patches?: readonly string[]
|
||||
/** Wait for the recorded provider title before sending each subsequent prompt. */
|
||||
@@ -127,6 +129,12 @@ interface SdkAssertions {
|
||||
}
|
||||
|
||||
const SDK_ASSERTIONS: Readonly<Record<string, SdkAssertions>> = {
|
||||
'approval': {
|
||||
approvalOutcomes: ['allowed-once', 'rejected'],
|
||||
environment: { DSH_PERMISSION_MODE: 'workspace-write' },
|
||||
patches: [fileURLToPath(new URL('./approval/runtime.cordis.yml', import.meta.url))],
|
||||
},
|
||||
|
||||
'desktop-context': {
|
||||
expectedFinalResponse: 'SDK snapshot OK',
|
||||
runtimeContext: {
|
||||
@@ -686,6 +694,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{
|
||||
|
||||
const createHarness = (model = route.model): DeepSeekHarness => new DeepSeekHarness({
|
||||
profile: scenario.manifest.profile,
|
||||
...(assertions.approvalOutcomes === undefined ? {} : { approvalRequests: true }),
|
||||
patches,
|
||||
dshHome,
|
||||
processCwd: cwd,
|
||||
@@ -703,6 +712,17 @@ async function runScenario(scenario: CorpusScenario): Promise<{
|
||||
const results: RunResult[] = []
|
||||
const sessionId = 'fixture-root-session'
|
||||
const liveSessions: (string | undefined)[] = [sessionId]
|
||||
if (assertions.approvalOutcomes !== undefined) {
|
||||
const outcomes = [...assertions.approvalOutcomes]
|
||||
harness.client.start()
|
||||
harness.client.onRequest(async (method, params) => {
|
||||
expect(method).toBe('approval/request')
|
||||
expect(params).toMatchObject({ sessionId, toolName: 'fixture-write' })
|
||||
const outcome = outcomes.shift()
|
||||
if (outcome === undefined) throw new Error('unexpected approval request')
|
||||
return { outcome }
|
||||
})
|
||||
}
|
||||
await harness.start()
|
||||
let subscription = harness.client.subscribeSessionTree(sessionId)
|
||||
const titleSubscription = assertions.waitForProviderTitle === true
|
||||
|
||||
Reference in new issue
Block a user