mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
feat(sdk): bridge internal approvals to stdio clients
This commit is contained in:
1 parent
147fee4186
commit
5d3dd1bbb0
41 files changed
+574
-18
No files matched your search
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -28,7 +28,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:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../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) |
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../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) |
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-sdk-client/use-this-package:
|
||||
en: ff421f12a5db28a4
|
||||
zh: 2ca9b71a3458b5e0
|
||||
en: 38bb14f9c588dea2
|
||||
zh: 3f8218a0eb4f7ec3
|
||||
/deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness:
|
||||
en: 01c58dddd7e62de2
|
||||
zh: 6dbd46e244f3f135
|
||||
|
||||
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
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. 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.
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
若由客户端处理审批,先调用 `harness.client.start()`,安装 `harness.client.onRequest(handler)`,再调用 `harness.start()`,并在 Harness 选项中设置 `approvalRequests: true`。处理器接收 `approval/request` 参数,返回 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`;处理器拒绝时返回 JSON-RPC 错误,运行时拒绝放行该审批。替换客户端时需要为新实例安装处理器;已关闭的客户端不能重启。未启用时保留现有审批应答链。请求字段与取消规则见[协议](../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` 作为首个参数。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -268,6 +268,18 @@ export class HarnessClient {
|
||||
this.transport = transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the server-request handler on a started client, replacing the prior handler.
|
||||
* @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.transport === undefined || this.closeTask !== undefined) {
|
||||
throw new TransportClosedError('start the SDK client before installing a request handler')
|
||||
}
|
||||
this.transport.onRequest(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the process-wide handshake.
|
||||
* @param params - workspace cwd plus the provider/model route.
|
||||
|
||||
@@ -64,6 +64,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. */
|
||||
|
||||
@@ -245,7 +245,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 +292,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,34 @@ 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('start the SDK 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 } })
|
||||
})
|
||||
})
|
||||
@@ -15,14 +15,14 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-sdk-protocol/use-this-package:
|
||||
en: c4ca91b51aab452a
|
||||
zh: 1c77b1deaeb34931
|
||||
en: 3b1752b7f9f6c317
|
||||
zh: 066b03b904b6448b
|
||||
/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: 8057053746fc2755
|
||||
zh: 36d5989488c1a351
|
||||
en: 5bd04dbafb8b0fab
|
||||
zh: de0225a5c9794c34
|
||||
/deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics:
|
||||
en: 3cf5d4ffbaf37f69
|
||||
zh: 23c4450e31c91d67
|
||||
|
||||
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
### Framing and transport
|
||||
@@ -33,7 +35,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: client-to-server requests, opt-in server-to-client requests, and server notifications.
|
||||
|
||||
| Direction | Method | Payload types |
|
||||
|---|---|---|
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
`InitializeParams.approvalRequests: true` 启用由 `HarnessSdkClientRequestMap` 索引的服务端请求 `approval/request`。`ApprovalRequestParams` 包含 `sessionId`、`toolName`,以及可选的 `callId` 和 `reason`;`ApprovalRequestResult` 为 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`。省略或设为 `false` 时保留现有应答链。只有 `allowed-once` 批准单次操作。取消会丢弃待处理请求及迟到响应;shutdown 和传输断开使待处理决定拒绝放行。客户端等待 Agent 完成期间,必须继续读取并回答请求。
|
||||
|
||||
当你构建或调试 SDK 协议端——服务插件、客户端库或使用该协议的自定义工具——时使用本包。它为你提供一个在调用方持有的字节流上承载 JSON-RPC 2.0 的传输,以及每个 SDK 方法与通知的类型化结构。
|
||||
|
||||
### 分帧与传输
|
||||
@@ -33,7 +35,7 @@ kind: "package-library"
|
||||
|
||||
### SDK 方法
|
||||
|
||||
两个协议端共享同一套方法:七个客户端到服务端请求与四个服务端到客户端通知。
|
||||
两个协议端共享同一套方法:客户端到服务端请求、显式启用的服务端到客户端请求,以及服务端通知。
|
||||
|
||||
| 方向 | 方法 | 载荷类型 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
|
||||
export type { JsonRpcTransportPeer } from './transport.ts'
|
||||
export type {
|
||||
ApprovalRequestParams,
|
||||
ApprovalRequestResult,
|
||||
HarnessSdkClientRequestMap,
|
||||
HarnessSdkNotificationMap,
|
||||
HarnessSdkRequestMap,
|
||||
InitializeParams,
|
||||
|
||||
@@ -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 waiter and rejects on abort.
|
||||
* @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<unknown>
|
||||
request(method: string, params: object, signal?: AbortSignal): Promise<unknown>
|
||||
/**
|
||||
* Send a notification; omitted params produce no `params` member.
|
||||
* @param method - the JSON-RPC method name.
|
||||
@@ -61,6 +62,7 @@ interface PendingRequest {
|
||||
*/
|
||||
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
private buffer = ''
|
||||
private failure: Error | undefined
|
||||
private readonly decoder = new StringDecoder('utf8')
|
||||
private started = false
|
||||
private requestHandler: RequestHandler | undefined
|
||||
@@ -119,6 +121,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) => {
|
||||
@@ -262,6 +265,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)
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
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`). */
|
||||
@@ -151,3 +153,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: e136ff3a0b42cc22
|
||||
zh: f6c4df5bbafa335f
|
||||
en: c7db38347b9da536
|
||||
zh: 05767011d6b389bf
|
||||
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring:
|
||||
en: 47a33e9397e36e15
|
||||
zh: e6d173820f9ea6e5
|
||||
|
||||
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
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; 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 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.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
将 `initialize.approvalRequests` 设为 `true`,即可把内部审批作为服务端到客户端的 `approval/request` 请求发送。桥接处理 SDK 拥有的 Agent 及其仍在运行的进程内后代;其他 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()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。
|
||||
|
||||
`session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@deepseek-ai/dsh-sdk-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -57,6 +58,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-working-directory": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,12 @@ 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 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,
|
||||
@@ -93,12 +95,32 @@ export class HarnessSdkJsonRpcServer {
|
||||
private shutdownTask: Promise<Record<string, never>> | undefined
|
||||
private shuttingDown = false
|
||||
private initialized = false
|
||||
private approvalRequests = false
|
||||
private readonly lifetime = new AbortController()
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
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.disposers.push(ctx.on('session/event', (session, event) => {
|
||||
const record = this.sessions.get(session.id)
|
||||
@@ -153,6 +175,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')
|
||||
@@ -184,6 +209,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
this.model = model
|
||||
this.reasoningEffort = reasoningEffort
|
||||
this.maxTokens = params.maxTokens
|
||||
this.approvalRequests = params.approvalRequests === true
|
||||
this.initialized = true
|
||||
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
|
||||
}
|
||||
@@ -256,6 +282,16 @@ export class HarnessSdkJsonRpcServer {
|
||||
}
|
||||
}
|
||||
|
||||
private ownsAgent(agent: Agent): boolean {
|
||||
let current: Agent | undefined = agent
|
||||
while (current !== undefined) {
|
||||
if (this.sessions.get(current.session.id)?.handle.agent === current) return true
|
||||
const parent: SessionId | undefined = current.session.header.parentSession
|
||||
current = parent === undefined ? undefined : this.ctx.agents.get(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}`)
|
||||
@@ -302,6 +338,7 @@ export class HarnessSdkJsonRpcServer {
|
||||
|
||||
private async performShutdown(): Promise<Record<string, never>> {
|
||||
this.shuttingDown = true
|
||||
this.lifetime.abort(new Error('SDK server is shutting down'))
|
||||
const pendingCreations = [...this.sessionCreations.values()]
|
||||
await Promise.allSettled(pendingCreations)
|
||||
this.sessionCreations.clear()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/** 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 } 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 }
|
||||
}
|
||||
|
||||
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')
|
||||
answer.resolve({ 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('does not claim an unrelated live Agent', async () => {
|
||||
const h = await setup(true)
|
||||
const foreign = await h.ctx.agents.create({ sessionId: SessionId('foreign'), meta: { cwd: process.cwd() } })
|
||||
onTestFinished(() => foreign.dispose())
|
||||
foreign.agent.session.append('turn/start', { turn: 1 })
|
||||
await expect(h.ctx.approval.request({ agent: foreign.agent, toolName: 'bash' })).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('serves in-process descendants and preserves never policy', async () => {
|
||||
const h = await setup(true)
|
||||
const child = await h.ctx.agents.create({ sessionId: SessionId('child'), meta: { cwd: process.cwd(), parentSession: h.agent.session.id } })
|
||||
onTestFinished(() => child.dispose())
|
||||
child.agent.session.append('turn/start', { turn: 1 })
|
||||
h.client.onRequest(async (_method, params) => {
|
||||
expect(params).toEqual({ sessionId: 'child', toolName: 'bash' })
|
||||
return { outcome: 'allowed-once' }
|
||||
})
|
||||
await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('allowed-once')
|
||||
h.ctx.approval.setPolicy(child.agent, 'never')
|
||||
await expect(h.ctx.approval.request({ agent: child.agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../session/working-directory"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/user-approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+3
@@ -12212,6 +12212,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-working-directory':
|
||||
specifier: workspace:*
|
||||
version: link:../../session/working-directory
|
||||
|
||||
@@ -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, -32601, "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, -32601, "Unsupported client request")
|
||||
```
|
||||
|
||||
## 自定义插件
|
||||
|
||||
持久自定义属于 `dsh` profile。使用运行时 wheel 包提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
|
||||
from .client import HarnessClient, HarnessConfig
|
||||
from .errors import SdkProtocolError
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
|
||||
from .models import ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
|
||||
|
||||
__all__ = [
|
||||
"DeepSeekHarness",
|
||||
@@ -11,6 +11,8 @@ __all__ = [
|
||||
"HarnessClient",
|
||||
"HarnessConfig",
|
||||
"SdkProtocolError",
|
||||
"ApprovalRequestParams",
|
||||
"ApprovalRequestResult",
|
||||
"IncomingRequest",
|
||||
"InitializeResponse",
|
||||
"JsonObject",
|
||||
|
||||
@@ -23,6 +23,7 @@ class DeepSeekHarnessConfig:
|
||||
model: str = "deepseek-v4-flash"
|
||||
reasoning_effort: str | None = None
|
||||
max_tokens: int | None = None
|
||||
approval_requests: bool = False
|
||||
cwd: str | None = None
|
||||
runtime_cwd: str | None = None
|
||||
dsh_bin: str | None = None
|
||||
@@ -110,6 +111,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
|
||||
|
||||
|
||||
@@ -138,12 +138,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
|
||||
|
||||
@@ -30,3 +30,18 @@ class ServerInfo(BaseModel):
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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, 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")
|
||||
@@ -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 / "apps/cli/tests/profiles/sdk/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: ../../../apps/cli/tests/profiles/sdk/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,11 @@ 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))],
|
||||
},
|
||||
'minimal-image-compaction': {
|
||||
expectedFinalResponse: 'MINIMAL_IMAGE_OK',
|
||||
expectedTools: { bash: ['command'], read_image: ['file_path'], working_directory: [] },
|
||||
@@ -648,6 +655,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{
|
||||
|
||||
const createHarness = (): DeepSeekHarness => new DeepSeekHarness({
|
||||
profile: scenario.manifest.profile,
|
||||
...(assertions.approvalOutcomes === undefined ? {} : { approvalRequests: true }),
|
||||
patches,
|
||||
dshHome,
|
||||
processCwd: cwd,
|
||||
@@ -665,6 +673,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