feat(sdk): route native commands through run
This commit is contained in:
1 parent
b367e53a6c
commit
e122cfb627
47 files changed
+880
-1369
No files matched your search
@@ -9,11 +9,11 @@
|
||||
en: ddf4dabea526cef1
|
||||
zh: 6e31cab10d2f3ed8
|
||||
/deepseek-harness-python-sdk/start-a-runtime:
|
||||
en: c4c30913fe2642ef
|
||||
zh: 0ed42bca37fba60b
|
||||
/deepseek-harness-python-sdk/query-and-response:
|
||||
en: 7cc5feefefd05142
|
||||
zh: ea346bf354ec266d
|
||||
en: ac793c588b13ce87
|
||||
zh: 502015cda46de3ac
|
||||
/deepseek-harness-python-sdk/run-text-and-commands:
|
||||
en: c781ae575e6be4d2
|
||||
zh: cc802c264f8a7a99
|
||||
/deepseek-harness-python-sdk/client-approvals:
|
||||
en: fc69be1beb8abff1
|
||||
zh: 91b56781931a6f68
|
||||
|
||||
+14
-12
@@ -33,7 +33,7 @@ The Python SDK has no separate application entrypoint. It launches the bundled `
|
||||
Every launch requires an explicit Harness home. Pass `dsh_home` or provide a non-empty `DSH_HOME` in the child environment. The SDK deliberately never discovers `~/.dsh`.
|
||||
|
||||
```py
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
from deepseek_harness import DeepSeekHarness, RunResult
|
||||
|
||||
with DeepSeekHarness(
|
||||
dsh_home="/absolute/path/to/isolated-dsh-home",
|
||||
@@ -45,10 +45,10 @@ with DeepSeekHarness(
|
||||
) as harness:
|
||||
result = harness.run("Say hi.", session_id="example-001")
|
||||
|
||||
print(result.final_response)
|
||||
print(result.final_response if isinstance(result, RunResult) else result.result.text)
|
||||
```
|
||||
|
||||
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
|
||||
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary activity remains unbounded after its protocol response, whose wait can be limited with `request_timeout_seconds`. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
|
||||
|
||||
`harness.resume_session(saved_session_id)` restores persisted history and returns a `Session` ready for `run()`. Save the original session id and reuse its `dsh_home` and workspace. Restoration preserves the stored directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions raise without creating a replacement. `start_session(id)` only opens a lazy handle; it does not request persisted restoration. This API does not restore shell, browser, or other external processes.
|
||||
|
||||
@@ -58,22 +58,24 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
result = session.run("Continue the task.")
|
||||
```
|
||||
|
||||
<a id="query-and-response"></a>
|
||||
## Query and response
|
||||
<a id="run-text-and-commands"></a>
|
||||
## Run text and commands
|
||||
|
||||
Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The runtime recognizes the [supported command forms and Desktop aliases](../../packages/sdk/server/README.md#command-input); unrecognized input remains ordinary text. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command.
|
||||
`session.run(input, on_notification=..., delivery="queue")` is the high-level input entry. Non-blank strings use the runtime's [supported command forms and Desktop aliases](../../packages/sdk/server/README.md#command-input); unrecognized input remains ordinary text. `delivery="steer"` targets the next available step for ordinary text. Command admission and timing remain owned by the native handler.
|
||||
|
||||
`receive_response()` claims the next query in send order. It first waits for the routing response, then yields `Notification` objects followed by one terminal value: `RunResult` for the prompt's inbox-receipt-to-idle interval, or `CommandExecution` after the command settles. Notifications can accumulate before receiving begins. Separate receivers can observe their queries concurrently, and another `query()` can be sent while a receiver waits. Close an unfinished iterator to release only its local observation; this does not cancel or roll back runtime work. Resources are released before yielding the terminal value.
|
||||
The call returns `RunResult` for an ordinary prompt's inbox-receipt-to-idle interval, or `CommandExecution` after a command settles. Use `isinstance(result, RunResult)` before reading `final_response`; otherwise inspect the command's `result.kind`. A command retains its `command_id` and optional `source_event_seq`, rather than selecting an earlier assistant reply.
|
||||
|
||||
`CommandExecution` contains `command_id` and a `result` discriminated by `kind`. Success may include `text` and `source_event_seq`; an error includes `text`. Handler errors return `kind="error"`; routing, protocol and transport failures raise during receiving. Sending failures raise from `query()`. `request_timeout_seconds` bounds waiting for the routing response, not later prompt activity; an already-arrived response remains receivable after that deadline.
|
||||
Content-block arrays stay literal queued prompts and return `RunResult`. For example, `session.run([{"type": "text", "text": "/compact"}])` sends that text to the model, while `session.run("/compact")` invokes native compaction. Content blocks require queue delivery; a steer request raises `ValueError` before sending.
|
||||
|
||||
The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` executes supported command names without prompt fallback; it requires a non-blank permission preset. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
|
||||
Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command notifications are delivered after the command response, ending at its own `command/done`. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity.
|
||||
|
||||
`/permission <preset>` delegates to the native permission handler. An unknown preset returns `CommandExecution.result.kind="error"` without changing permission state. A successful change belongs to that Session: later queries and restoration retain it, other Sessions retain their own state, and new Sessions use configured defaults. `DeepSeekHarnessConfig` remains startup input, not a shared live state mirror; the command does not rewrite it or saved user defaults. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions).
|
||||
The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` executes supported command names without prompt fallback. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
|
||||
|
||||
`/plan` enters plan mode, `/plan off` leaves, and `/plan <message>` enters then submits the trimmed message through native steer. Its `CommandExecution` confirms command completion, not completion of planning or review. To collect a normal activity result, enter with `/plan` then submit a separate ordinary query. For combined input, subscribe before sending and use `session/wait` while keeping the question handler alive, as in the [plan example](examples/README.md#review-a-plan). Plan mode changes guidance and progressively admits `exit_plan_mode`; it does not change permission settings. The default guidance prohibits file edits, including plan documents, while execution remains governed by the sandbox.
|
||||
`/permission <preset>` changes only the receiving Session. An unknown preset returns a command error without changing policy. Later input and restoration retain successful changes; other Sessions and the caller's `DeepSeekHarnessConfig` keep their own values. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions).
|
||||
|
||||
`/goal` shows the current goal; `/goal <objective>`, `edit <objective>`, `pause`, `resume`, and `clear` use the native goal handler. Creation and resume can start automatic rounds after the command returns. Subscribe before sending; observe terminal `goal/change` operations, then call `session/wait` for any closing model reply. Idle between rounds is not goal completion. A restored active goal stays disarmed until explicit resume. Goal commands preserve permission settings and startup configuration. See the [goal example](examples/README.md#run-a-goal).
|
||||
`/plan` enters plan mode, `/plan off` leaves, and `/plan <message>` enters then submits the trimmed message through native steer. Its `CommandExecution` confirms command completion, not completion of planning or review. Enter with `/plan` then run ordinary text when a `RunResult` is needed. For combined input, subscribe before `run()` and use `session/wait` while keeping the question handler alive, as in the [plan example](examples/README.md#review-a-plan). Plan guidance and `exit_plan_mode` follow their native lifecycle without changing permission settings.
|
||||
|
||||
`/goal` shows the current goal; `/goal <objective>`, `edit <objective>`, `pause`, `resume`, and `clear` use the native handler. Creation and resume can start automatic rounds after `run()` returns. Subscribe before sending, observe terminal `goal/change` operations, then call `session/wait` for any closing model reply. Idle between rounds is not goal completion. A restored active goal stays disarmed until explicit resume. See the [goal example](examples/README.md#run-a-goal).
|
||||
|
||||
<a id="client-approvals"></a>
|
||||
## Client approvals
|
||||
|
||||
+14
-12
@@ -33,7 +33,7 @@ Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的
|
||||
每次启动都必须显式指定 Harness home。请传入 `dsh_home`,或在子进程环境中提供非空的 `DSH_HOME`。SDK 刻意不会发现 `~/.dsh`。
|
||||
|
||||
```py
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
from deepseek_harness import DeepSeekHarness, RunResult
|
||||
|
||||
with DeepSeekHarness(
|
||||
dsh_home="/absolute/path/to/isolated-dsh-home",
|
||||
@@ -45,10 +45,10 @@ with DeepSeekHarness(
|
||||
) as harness:
|
||||
result = harness.run("Say hi.", session_id="example-001")
|
||||
|
||||
print(result.final_response)
|
||||
print(result.final_response if isinstance(result, RunResult) else result.result.text)
|
||||
```
|
||||
|
||||
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。
|
||||
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通活动在协议响应后仍不设时限;`request_timeout_seconds` 可以限制协议响应的等待。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。
|
||||
|
||||
`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。
|
||||
|
||||
@@ -58,22 +58,24 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
result = session.run("Continue the task.")
|
||||
```
|
||||
|
||||
<a id="query-and-response"></a>
|
||||
## 查询与接收
|
||||
<a id="run-text-and-commands"></a>
|
||||
## 运行文本与命令
|
||||
|
||||
调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。运行时识别[支持的命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input);未识别的输入保持普通文本。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。
|
||||
`session.run(input, on_notification=..., delivery="queue")` 是高层输入入口。非空白字符串使用运行时的[命令形式与 Desktop 别名](../../packages/sdk/server/README.zh.md#command-input)识别规则;未识别的输入保持普通文本。对普通文本,`delivery="steer"` 指向下一个可用步骤。命令的接纳规则与执行时序仍由原生处理器负责。
|
||||
|
||||
`receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。
|
||||
普通提示词返回其入队回执到下一次 idle 区间的 `RunResult`,命令则在执行结束后返回 `CommandExecution`。读取 `final_response` 前使用 `isinstance(result, RunResult)` 检查类型,否则检查命令的 `result.kind`。命令保留自己的 `command_id` 和可选 `source_event_seq`,不会选取更早的助手回复作为结果。
|
||||
|
||||
`CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。
|
||||
内容块数组保持原样排队输入,并返回 `RunResult`。例如,`session.run([{"type": "text", "text": "/compact"}])` 将该文本发送给模型,而 `session.run("/compact")` 调用原生压缩。内容块要求 queue 投递;请求 steer 会在发送前抛出 `ValueError`。
|
||||
|
||||
完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 执行支持的命令名,不回退为提示词;permission preset 必须非空。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令通知在命令响应到达后交付,止于其自身的 `command/done`。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。
|
||||
|
||||
`/permission <preset>` 委托给原生权限 handler。未知 preset 返回 `CommandExecution.result.kind="error"`,不改变权限状态。成功的变更属于该 Session:后续 query 和恢复保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。`DeepSeekHarnessConfig` 仍是启动输入,不是共享的实时状态镜像;命令不重写它或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外模型 step。另见[权限示例](examples/README.zh.md#change-session-permissions)。
|
||||
默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 严格执行支持的命令名,不回退为提示词。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
|
||||
`/plan` 进入计划模式,`/plan off` 退出,`/plan <message>` 进入后通过原生 steer 提交去除首尾空白的正文。返回的 `CommandExecution` 只表示命令完成,不表示规划或审查结束。需要普通活动结果时,可以先用 `/plan` 进入,再单独提交普通 query。组合输入则应在发送前订阅,并在保持问答处理器运行的同时使用 `session/wait`,见[计划示例](examples/README.zh.md#review-a-plan)。计划模式改变指导并渐进披露 `exit_plan_mode`,不修改权限设置。默认指导禁止编辑文件,包括计划文档;实际执行仍由沙箱控制。
|
||||
`/permission <preset>` 只修改接收命令的 Session。未知 preset 返回命令错误,不改变策略。后续输入与恢复保留成功的修改,其他 Session 和调用方的 `DeepSeekHarnessConfig` 保留自己的值。运行中的审批策略变化可以注入通知并触发下一模型步骤。见[权限示例](examples/README.zh.md#change-session-permissions)。
|
||||
|
||||
`/goal` 显示当前目标;`/goal <objective>`、`edit <objective>`、`pause`、`resume` 和 `clear` 使用原生目标 handler。创建和恢复可在命令返回后启动自动轮次。发送前应订阅通知,观察终态 `goal/change` 操作,再调用 `session/wait` 等待可能的模型收尾回复。轮次之间的 idle 不代表目标完成。恢复的活动目标保持 disarmed,直到显式恢复。目标命令保留权限设置和启动配置。另见[目标示例](examples/README.zh.md#run-a-goal)。
|
||||
`/plan` 进入计划模式,`/plan off` 退出,`/plan <message>` 进入后通过原生 steer 提交去除首尾空白的正文。其 `CommandExecution` 确认命令执行结束,不代表计划生成或审阅结束。需要 `RunResult` 时,先用 `/plan` 进入,再运行普通文本。组合输入应在 `run()` 前订阅,并保持问答处理器存活,通过 `session/wait` 观察后续工作,见[计划示例](examples/README.zh.md#review-a-plan)。计划指导和 `exit_plan_mode` 遵循原生生命周期,不修改权限设置。
|
||||
|
||||
`/goal` 查看当前目标;`/goal <objective>`、`edit <objective>`、`pause`、`resume` 和 `clear` 使用原生处理器。创建和恢复可以在 `run()` 返回后启动自动轮次。发送前订阅,观察终态 `goal/change` 操作后,再调用 `session/wait` 等待可能的模型收尾。轮次之间的 idle 不代表目标完成。恢复的活动目标在显式 resume 前保持 disarmed。见[目标示例](examples/README.zh.md#run-a-goal)。
|
||||
|
||||
<a id="client-approvals"></a>
|
||||
## 客户端审批
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
en: c97ffc5ce0f7db05
|
||||
zh: bdd91202075498a3
|
||||
/python-sdk-example/run-the-minimal-agent:
|
||||
en: 4b79fd6576e2759d
|
||||
en: ac09bb6bc3ab76a0
|
||||
zh: 6258540acaf4bc37
|
||||
/python-sdk-example/compact-a-full-sdk-session:
|
||||
en: ad4d67225570ed01
|
||||
zh: 0d59f6dac40129d6
|
||||
en: 7749773f97211ac1
|
||||
zh: 594348ae66a2ef6c
|
||||
/python-sdk-example/change-session-permissions:
|
||||
en: 3471642d8444d0e9
|
||||
zh: 7831f28295a6e68a
|
||||
en: 1fe5e06c6842a53b
|
||||
zh: ff0836d8cffaeef7
|
||||
/python-sdk-example/review-a-plan:
|
||||
en: 46758c022608af0f
|
||||
zh: 09b5a59176c11176
|
||||
en: 2596f68bb55082c8
|
||||
zh: 1e40ca75e1a0f9b9
|
||||
/python-sdk-example/run-a-goal:
|
||||
en: ae73e9fd9d402047
|
||||
zh: f9694bc6a6e12929
|
||||
|
||||
@@ -25,14 +25,14 @@ The shipped [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../packages/bundle/sdk
|
||||
- `read_image` for PNG/JPEG/WebP/GIF files, with an image-capable model
|
||||
- `working_directory` to inspect or change the Session directory
|
||||
|
||||
The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, text file tools, local instruction discovery, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. [Automatic context compaction](../../../packages/bundle/sdk-minimal/README.md#model-experience) summarizes older history through an additional model request while preserving the complete log. SDK prompt text does not execute slash commands.
|
||||
The bundle does not include `dsh-base`, so every additional row is an explicit profile change. Runtime context, text file tools, local instruction discovery, settings, managed credentials, telemetry, Web tools, subagents, and the full default tool roster are absent. The tree retains SDK startup and JSON-RPC serving, one environment-configured DeepSeek adapter, local execution, and JSONL persistence. [Automatic context compaction](../../../packages/bundle/sdk-minimal/README.md#model-experience) summarizes older history through an additional model request while preserving the complete log. This minimal composition leaves unsupported slash input on the ordinary prompt path.
|
||||
|
||||
The persistent PTY can modify any path available to the runtime process, so use a disposable checkout or container.
|
||||
|
||||
<a id="compact-a-full-sdk-session"></a>
|
||||
## Compact a full SDK Session
|
||||
|
||||
The [compaction example](compact.py) uses the full `sdk` profile. It reads `README.md` from the selected workspace into a `Session.query()` prompt, sends `/compact` through the same method, then asks a follow-up using the retained context. Each query is paired with `Session.receive_response()`. It uses the same model credentials and `DSH_MODEL` default as the minimal example, and requires an explicit home:
|
||||
The [compaction example](compact.py) uses the full `sdk` profile. It reads `README.md` from the selected workspace into a `Session.run()` prompt, executes `/compact` through the same method, then asks a follow-up using the retained context. It uses the same model credentials and `DSH_MODEL` default as the minimal example, and requires an explicit home:
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/compact.py \
|
||||
@@ -40,12 +40,12 @@ python python/sdk/examples/compact.py \
|
||||
--workspace /absolute/path/to/disposable-workspace
|
||||
```
|
||||
|
||||
The script consumes notifications and prints the command id and outcome separately from the model's responses. A failed turn or command stops the example. See [Query and response](../README.md#query-and-response) for completion and error behavior; `minimal.py` retains its `run()` entry and does not enable command execution.
|
||||
The script prints command ids and outcomes separately from model responses. A failed turn or command stops the example. See [Run text and commands](../README.md#run-text-and-commands) for completion and error behavior; `minimal.py` uses the same `run()` entry, with capabilities supplied by its selected profile.
|
||||
|
||||
<a id="change-session-permissions"></a>
|
||||
## Change Session permissions
|
||||
|
||||
The [permission example](permission.py) starts the full `sdk` profile with a `workspace-write` default, sends `/permission <preset>`, then submits an ordinary query on the same Session. Supply the preset explicitly:
|
||||
The [permission example](permission.py) starts the full `sdk` profile with a `workspace-write` default, sends `/permission <preset>`, then runs ordinary text on the same Session. Supply the preset explicitly:
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/permission.py \
|
||||
@@ -54,12 +54,12 @@ python python/sdk/examples/permission.py \
|
||||
--preset read-only
|
||||
```
|
||||
|
||||
The command changes the Session's policy while the printed launch setting remains `workspace-write`. The later query uses the Session's current policy. Bare `/permission` is ordinary text, and unknown presets produce a command error. See [Query and response](../README.md#query-and-response) for result and state semantics.
|
||||
The command changes the Session's policy while the printed launch setting remains `workspace-write`. The later input uses the Session's current policy. Bare `/permission` is ordinary text, and unknown presets produce a command error. See [Run text and commands](../README.md#run-text-and-commands) for result and state semantics.
|
||||
|
||||
<a id="review-a-plan"></a>
|
||||
## Review a plan
|
||||
|
||||
The [plan example](plan.py) sends `/plan <task>` through query and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; EOF dismisses without approving. Approval allows implementation from a later model step, so choose an isolated workspace.
|
||||
The [plan example](plan.py) sends `/plan <task>` through `run()` and keeps the Session notification subscription and question handler alive after command completion. Enter `a` to approve, `k` to give feedback, or `d` to dismiss; EOF dismisses without approving. Approval allows implementation from a later model step, so choose an isolated workspace.
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/plan.py \
|
||||
|
||||
@@ -32,7 +32,7 @@ python python/sdk/examples/minimal.py \
|
||||
<a id="compact-a-full-sdk-session"></a>
|
||||
## 压缩完整 SDK Session
|
||||
|
||||
[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.query()` 输入,通过同一方法发送 `/compact`,随后使用保留的上下文继续提问。每次 query 都配对调用 `Session.receive_response()`。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home:
|
||||
[压缩示例](compact.py)使用完整 `sdk` profile。它将所选工作区的 `README.md` 读入 `Session.run()` 输入,通过同一方法执行 `/compact`,随后使用保留的上下文继续提问。模型凭据和 `DSH_MODEL` 默认值与极简示例一致,并且要求显式指定 home:
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/compact.py \
|
||||
@@ -40,12 +40,12 @@ python python/sdk/examples/compact.py \
|
||||
--workspace /absolute/path/to/disposable-workspace
|
||||
```
|
||||
|
||||
脚本消费通知,并将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[查询与接收](../README.zh.md#query-and-response);`minimal.py` 保留其 `run()` 入口,不启用命令执行。
|
||||
脚本将命令 id 和结果与模型回复分开打印。轮次或命令失败时,示例会停止。完成和错误行为见[运行文本与命令](../README.zh.md#run-text-and-commands);`minimal.py` 使用同一个 `run()` 入口,能力由所选 profile 提供。
|
||||
|
||||
<a id="change-session-permissions"></a>
|
||||
## 修改 Session 权限
|
||||
|
||||
[权限示例](permission.py)以 `workspace-write` 为默认值启动完整 `sdk` profile,发送 `/permission <preset>`,随后在同一个 Session 中提交普通 query。请显式指定 preset:
|
||||
[权限示例](permission.py)以 `workspace-write` 为默认值启动完整 `sdk` profile,发送 `/permission <preset>`,随后在同一个 Session 中运行普通文本。请显式指定 preset:
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/permission.py \
|
||||
@@ -54,12 +54,12 @@ python python/sdk/examples/permission.py \
|
||||
--preset read-only
|
||||
```
|
||||
|
||||
命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续 query 使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[查询与接收](../README.zh.md#query-and-response)。
|
||||
命令会修改 Session 的策略,而打印出的启动设置仍为 `workspace-write`。后续输入使用 Session 的当前策略。裸 `/permission` 是普通文本,未知 preset 返回命令错误。结果和状态语义见[运行文本与命令](../README.zh.md#run-text-and-commands)。
|
||||
|
||||
<a id="review-a-plan"></a>
|
||||
## 审查计划
|
||||
|
||||
[计划示例](plan.py) 通过 query 发送 `/plan <task>`,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。
|
||||
[计划示例](plan.py) 通过 `run()` 发送 `/plan <task>`,并在命令完成后保持 Session 通知订阅和问答处理器运行。输入 `a` 批准、`k` 提供反馈、`d` 关闭;EOF 也会关闭而不批准。批准后模型可以从后续步骤开始实施,因此应选择隔离的工作区。
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/plan.py \
|
||||
|
||||
@@ -7,22 +7,21 @@ import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult
|
||||
|
||||
|
||||
def receive(session: Session) -> None:
|
||||
"""Consume notifications and print the query's command or activity result."""
|
||||
for result in session.receive_response():
|
||||
if isinstance(result, RunResult):
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}")
|
||||
print(result.final_response)
|
||||
elif isinstance(result, CommandExecution):
|
||||
print(f"Command {result.command_id}: {result.result.kind}")
|
||||
if result.result.kind == "error":
|
||||
raise RuntimeError(result.result.text)
|
||||
if result.result.text is not None:
|
||||
print(result.result.text)
|
||||
def display(result: RunResult | CommandExecution) -> None:
|
||||
"""Print the input's result and stop on a reported failure."""
|
||||
if isinstance(result, CommandExecution):
|
||||
print(f"Command {result.command_id}: {result.result.kind}")
|
||||
if result.result.kind == "error":
|
||||
raise RuntimeError(result.result.text)
|
||||
if result.result.text is not None:
|
||||
print(result.result.text)
|
||||
else:
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}")
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -50,15 +49,12 @@ def main() -> None:
|
||||
max_tokens=args.max_tokens,
|
||||
) as harness:
|
||||
session = harness.start_session(args.session_id)
|
||||
session.query(
|
||||
display(session.run(
|
||||
"Read this project README and summarize its purpose and development commands without using tools.\n\n"
|
||||
+ readme.read_text(encoding="utf-8")
|
||||
)
|
||||
receive(session)
|
||||
session.query("/compact")
|
||||
receive(session)
|
||||
session.query("What are the project's purpose and development commands? Use the earlier README without tools.")
|
||||
receive(session)
|
||||
))
|
||||
display(session.run("/compact"))
|
||||
display(session.run("What are the project's purpose and development commands? Use the earlier README without tools."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -52,12 +52,12 @@ def main() -> None:
|
||||
session = harness.start_session()
|
||||
with harness.client.subscribe_notifications(lambda notice: notice.payload.get("sessionId") == session.id) as notifications:
|
||||
deadline = time.monotonic() + args.timeout_seconds
|
||||
session.query(f"/goal {args.objective}")
|
||||
for message in session.receive_response():
|
||||
if isinstance(message, CommandExecution):
|
||||
print(message.result.text, flush=True)
|
||||
if message.result.kind == "error":
|
||||
raise RuntimeError(message.result.text)
|
||||
execution = session.run(f"/goal {args.objective}")
|
||||
if not isinstance(execution, CommandExecution):
|
||||
raise RuntimeError("The SDK profile did not execute the requested command")
|
||||
print(execution.result.text, flush=True)
|
||||
if execution.result.kind == "error":
|
||||
raise RuntimeError(execution.result.text)
|
||||
while True:
|
||||
event = display(notifications.next(timeout_seconds=max(0, deadline - time.monotonic())))
|
||||
if event is not None and event["type"] == "goal/change" and event["data"]["operation"] in {
|
||||
|
||||
@@ -7,7 +7,7 @@ import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -41,7 +41,12 @@ def main() -> None:
|
||||
profile=args.profile,
|
||||
) as harness:
|
||||
result = harness.run(args.prompt, session_id=args.session_id)
|
||||
print(result.final_response)
|
||||
if isinstance(result, CommandExecution):
|
||||
if result.result.kind == "error":
|
||||
raise RuntimeError(result.result.text)
|
||||
print(result.result.text or "")
|
||||
else:
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,30 +5,27 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult, Session
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness, RunResult
|
||||
|
||||
|
||||
def receive(session: Session) -> None:
|
||||
"""Print a command or activity result and stop on a reported failure."""
|
||||
with closing(session.receive_response()) as responses:
|
||||
for result in responses:
|
||||
if isinstance(result, CommandExecution):
|
||||
print(f"Command {result.command_id}: {result.result.kind}")
|
||||
if result.result.kind == "error":
|
||||
raise RuntimeError(result.result.text)
|
||||
if result.result.text is not None:
|
||||
print(result.result.text)
|
||||
elif isinstance(result, RunResult):
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Agent turn ended with {result.finish_reason}")
|
||||
print(result.final_response)
|
||||
def display(result: RunResult | CommandExecution) -> None:
|
||||
"""Print the input's result and stop on a reported failure."""
|
||||
if isinstance(result, CommandExecution):
|
||||
print(f"Command {result.command_id}: {result.result.kind}")
|
||||
if result.result.kind == "error":
|
||||
raise RuntimeError(result.result.text)
|
||||
if result.result.text is not None:
|
||||
print(result.result.text)
|
||||
else:
|
||||
if result.finish_reason != "completed":
|
||||
raise RuntimeError(f"Agent turn ended with {result.finish_reason}: {result.final_response}")
|
||||
print(result.final_response)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Send an explicit permission command, then query the same Session."""
|
||||
"""Send an explicit permission command, then run another prompt in the same Session."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsh-home", required=True, type=Path)
|
||||
parser.add_argument("--workspace", type=Path, default=Path.cwd())
|
||||
@@ -45,13 +42,10 @@ def main() -> None:
|
||||
env={"DSH_PERMISSION_MODE": "workspace-write"},
|
||||
) as harness:
|
||||
session = harness.start_session()
|
||||
session.query("Summarize the current file and approval policy in one sentence. Do not use tools.")
|
||||
receive(session)
|
||||
session.query(f"/permission {args.preset}")
|
||||
receive(session)
|
||||
display(session.run("Summarize the current file and approval policy in one sentence. Do not use tools."))
|
||||
display(session.run(f"/permission {args.preset}"))
|
||||
print(f"Launch setting: {harness.config.env['DSH_PERMISSION_MODE']}")
|
||||
session.query("Summarize the current file and approval policy again. Do not use tools.")
|
||||
receive(session)
|
||||
display(session.run("Summarize the current file and approval policy again. Do not use tools."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -72,12 +72,12 @@ def main() -> None:
|
||||
) as harness:
|
||||
session = harness.start_session()
|
||||
with closing(harness.client.subscribe_session_notifications(session.id)) as notifications:
|
||||
session.query(f"/plan {args.task}")
|
||||
for message in session.receive_response():
|
||||
if isinstance(message, CommandExecution):
|
||||
print(message.result.text)
|
||||
if message.result.kind == "error":
|
||||
raise RuntimeError(message.result.text)
|
||||
execution = session.run(f"/plan {args.task}")
|
||||
if not isinstance(execution, CommandExecution):
|
||||
raise RuntimeError("The SDK profile did not execute the requested command")
|
||||
print(execution.result.text, flush=True)
|
||||
if execution.result.kind == "error":
|
||||
raise RuntimeError(execution.result.text)
|
||||
harness.client.request(
|
||||
"session/wait", {"sessionId": session.id}, response_model=WaitResult,
|
||||
on_notification=lambda notice: display(notice, session.id), notification_subscription=notifications,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import threading
|
||||
from collections import deque
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Literal
|
||||
from typing import Callable, Literal, overload
|
||||
|
||||
from .client import HarnessClient, HarnessConfig, NotificationSubscription, UserQuestionHandler, _PendingRequest
|
||||
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
|
||||
from .errors import SdkProtocolError, TransportClosedError
|
||||
from .models import CommandExecution, JsonObject, Notification
|
||||
|
||||
@@ -84,101 +81,6 @@ class _PromptActivity:
|
||||
)
|
||||
|
||||
|
||||
class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecution]):
|
||||
def __init__(
|
||||
self, client: HarnessClient, session_id: str, request: _PendingRequest,
|
||||
subscription: NotificationSubscription,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._session_id = session_id
|
||||
self._request = request
|
||||
self._subscription = subscription
|
||||
self._lock = threading.Lock()
|
||||
self._closed = False
|
||||
self._released = False
|
||||
self._routed = False
|
||||
self._activity: _PromptActivity | None = None
|
||||
self._buffer: deque[Notification] = deque()
|
||||
self._terminal: RunResult | CommandExecution | None = None
|
||||
|
||||
def _release(self, error: BaseException | None = None) -> None:
|
||||
with self._lock:
|
||||
if self._released:
|
||||
return
|
||||
self._released = True
|
||||
self._client._discard_request(self._request, error)
|
||||
if error is None:
|
||||
self._subscription.close()
|
||||
else:
|
||||
self._subscription._cancel(error)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop observing this query; the runtime operation is not cancelled."""
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._release(TransportClosedError("query response iterator closed"))
|
||||
|
||||
def _ensure_open(self) -> None:
|
||||
if self._closed:
|
||||
raise StopIteration
|
||||
|
||||
def __next__(self) -> Notification | RunResult | CommandExecution:
|
||||
try:
|
||||
self._ensure_open()
|
||||
if not self._routed:
|
||||
response = self._client._wait_request(self._request)
|
||||
self._ensure_open()
|
||||
self._routed = True
|
||||
if not isinstance(response, dict):
|
||||
raise SdkProtocolError("session/query response must be a JSON object")
|
||||
if response.get("kind") == "command":
|
||||
self._terminal = CommandExecution.model_validate(response.get("execution"))
|
||||
self._release()
|
||||
while True:
|
||||
try:
|
||||
notification = self._subscription._next_queued()
|
||||
except TransportClosedError:
|
||||
break
|
||||
if notification is None:
|
||||
break
|
||||
self._buffer.append(notification)
|
||||
if _is_command_done(notification, self._session_id, self._terminal.command_id):
|
||||
break
|
||||
elif response.get("kind") == "prompt" and isinstance(response.get("messageId"), str) and response["messageId"]:
|
||||
self._activity = _PromptActivity(self._session_id, response["messageId"])
|
||||
else:
|
||||
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
|
||||
self._ensure_open()
|
||||
if self._buffer:
|
||||
return self._buffer.popleft()
|
||||
if self._terminal is not None:
|
||||
terminal = self._terminal
|
||||
self._terminal = None
|
||||
self._release()
|
||||
self._closed = True
|
||||
return terminal
|
||||
assert self._activity is not None
|
||||
while True:
|
||||
notification = self._subscription.next()
|
||||
self._ensure_open()
|
||||
if not self._activity.record(notification):
|
||||
continue
|
||||
if (notification.method == "session.status"
|
||||
and notification.payload.get("sessionId") == self._session_id
|
||||
and notification.payload.get("status") == "idle"):
|
||||
self._terminal = self._activity.result()
|
||||
self._release()
|
||||
return notification
|
||||
except BaseException as error:
|
||||
locally_closed = self._closed
|
||||
self.close()
|
||||
if locally_closed and isinstance(error, (TransportClosedError, StopIteration)):
|
||||
raise StopIteration
|
||||
raise
|
||||
|
||||
|
||||
class DeepSeekHarness:
|
||||
"""Reusable synchronous SDK for running DeepSeek Harness agent turns.
|
||||
|
||||
@@ -265,55 +167,35 @@ class DeepSeekHarness:
|
||||
self._client.resume_session(session_id)
|
||||
return Session(self, session_id)
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self, input: list[JsonObject], *, session_id: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> RunResult: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self, input: str | list[JsonObject], *, session_id: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
delivery: Literal["queue", "steer"] = "queue",
|
||||
) -> RunResult | CommandExecution: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str | list[JsonObject],
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> RunResult:
|
||||
return self.start_session(session_id).run(input, on_notification=on_notification)
|
||||
delivery: Literal["queue", "steer"] = "queue",
|
||||
) -> RunResult | CommandExecution:
|
||||
"""Run text or a native command on a fresh or named Session."""
|
||||
return self.start_session(session_id).run(input, on_notification=on_notification, delivery=delivery)
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, harness: DeepSeekHarness, session_id: str) -> None:
|
||||
self.harness = harness
|
||||
self.id = session_id
|
||||
self._queries: deque[_QueryResponseIterator] = deque()
|
||||
self._query_lock = threading.Lock()
|
||||
|
||||
def query(self, text: str, *, delivery: Literal["queue", "steer"] = "queue") -> None:
|
||||
"""Send text for runtime prompt or command routing without waiting for the response.
|
||||
|
||||
Pair each call with receive_response(). Delivery selects where ordinary
|
||||
prompts enter the inbox; commands retain their native execution timing.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
raise TypeError("query text must be a string")
|
||||
if delivery not in ("queue", "steer"):
|
||||
raise ValueError("query delivery must be queue or steer")
|
||||
self.harness.start()
|
||||
client = self.harness.client
|
||||
with self._query_lock:
|
||||
subscription = client.subscribe_session_notifications(self.id)
|
||||
try:
|
||||
request = client._begin_request("session/query", {"sessionId": self.id, "text": text, "delivery": delivery})
|
||||
except BaseException:
|
||||
subscription.close()
|
||||
raise
|
||||
self._queries.append(_QueryResponseIterator(client, self.id, request, subscription))
|
||||
|
||||
def receive_response(self) -> _QueryResponseIterator:
|
||||
"""Claim the next query in send order and iterate its notifications and terminal result.
|
||||
|
||||
Prompts collect the inbox-receipt-to-idle interval used by run(); commands
|
||||
finish on their RPC result. Close an unfinished iterator to release its
|
||||
local observation resources without cancelling the runtime operation.
|
||||
"""
|
||||
with self._query_lock:
|
||||
if not self._queries:
|
||||
raise RuntimeError("receive_response requires a preceding query")
|
||||
return self._queries.popleft()
|
||||
|
||||
def get_working_directory(self) -> str:
|
||||
"""Read the effective directory, recovering a missing directory to the Session origin."""
|
||||
@@ -325,20 +207,68 @@ class Session:
|
||||
self.harness.start()
|
||||
return self.harness.client.set_working_directory(self.id, path)
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self, input: list[JsonObject], *,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> RunResult: ...
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self, input: str | list[JsonObject], *,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
delivery: Literal["queue", "steer"] = "queue",
|
||||
) -> RunResult | CommandExecution: ...
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str | list[JsonObject],
|
||||
*,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> RunResult:
|
||||
content_blocks = normalize_input(input)
|
||||
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
|
||||
message_id = self.harness.client.session_prompt(
|
||||
self.id,
|
||||
content_blocks,
|
||||
notification_subscription=subscription,
|
||||
)
|
||||
delivery: Literal["queue", "steer"] = "queue",
|
||||
) -> RunResult | CommandExecution:
|
||||
"""Run text through native input routing, or queue literal content blocks.
|
||||
|
||||
Text returns the prompt's receipt-to-idle RunResult or a settled
|
||||
CommandExecution; command completion does not wait for later model work.
|
||||
Content blocks stay literal and accept only queue delivery. Notifications
|
||||
retain wire order; command notifications are delivered after its response,
|
||||
up to its own command/done event. Handler errors remain command results;
|
||||
routing, transport and observer failures raise without rolling back work.
|
||||
"""
|
||||
if delivery not in ("queue", "steer"):
|
||||
raise ValueError("run delivery must be queue or steer")
|
||||
if not isinstance(input, str) and delivery != "queue":
|
||||
raise ValueError("content-block input requires queue delivery")
|
||||
self.harness.start()
|
||||
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
|
||||
if isinstance(input, str):
|
||||
response = self.harness.client._request_raw(
|
||||
"session/query", {"sessionId": self.id, "text": input, "delivery": delivery},
|
||||
)
|
||||
if not isinstance(response, dict):
|
||||
raise SdkProtocolError("session/query response must be a JSON object")
|
||||
if response.get("kind") == "command":
|
||||
execution = CommandExecution.model_validate(response.get("execution"))
|
||||
while True:
|
||||
try:
|
||||
notification = subscription._next_queued()
|
||||
except TransportClosedError:
|
||||
break
|
||||
if notification is None:
|
||||
break
|
||||
if on_notification is not None:
|
||||
on_notification(notification)
|
||||
if _is_command_done(notification, self.id, execution.command_id):
|
||||
break
|
||||
return execution
|
||||
if response.get("kind") != "prompt" or not isinstance(response.get("messageId"), str) or not response["messageId"]:
|
||||
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
|
||||
message_id = response["messageId"]
|
||||
else:
|
||||
message_id = self.harness.client.session_prompt(
|
||||
self.id, input, notification_subscription=subscription,
|
||||
)
|
||||
activity = _PromptActivity(self.id, message_id)
|
||||
while True:
|
||||
notification = subscription.next()
|
||||
|
||||
@@ -39,14 +39,6 @@ class HarnessConfig:
|
||||
on_user_question: UserQuestionHandler | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PendingRequest:
|
||||
request_id: str
|
||||
method: str
|
||||
waiter: queue.Queue[JsonValue | BaseException]
|
||||
deadline: float | None
|
||||
|
||||
|
||||
class HarnessClient:
|
||||
"""Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio."""
|
||||
|
||||
@@ -340,29 +332,15 @@ class HarnessClient:
|
||||
notification_filter: NotificationFilter | None = None,
|
||||
notification_subscription: "NotificationSubscription | None" = None,
|
||||
) -> JsonValue:
|
||||
request_id = str(uuid.uuid4())
|
||||
waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
|
||||
temp_subscription: NotificationSubscription | None = None
|
||||
subscription = notification_subscription
|
||||
with self._lock:
|
||||
self._responses[request_id] = waiter
|
||||
if on_notification is not None and subscription is None:
|
||||
temp_subscription = self.subscribe_notifications(notification_filter)
|
||||
subscription = temp_subscription
|
||||
try:
|
||||
pending = self._begin_request(method, params, timeout_seconds=timeout_seconds)
|
||||
return self._wait_request(pending, on_notification=on_notification, notification_subscription=subscription)
|
||||
finally:
|
||||
if temp_subscription is not None:
|
||||
temp_subscription.close()
|
||||
|
||||
def _begin_request(
|
||||
self,
|
||||
method: str,
|
||||
params: JsonObject | None = None,
|
||||
*,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> _PendingRequest:
|
||||
request_id = str(uuid.uuid4())
|
||||
waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
|
||||
with self._lock:
|
||||
self._responses[request_id] = waiter
|
||||
try:
|
||||
message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
@@ -371,59 +349,48 @@ class HarnessClient:
|
||||
except BaseException:
|
||||
with self._lock:
|
||||
self._responses.pop(request_id, None)
|
||||
if temp_subscription is not None:
|
||||
temp_subscription.close()
|
||||
raise
|
||||
timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
return _PendingRequest(request_id, method, waiter, deadline)
|
||||
|
||||
def _discard_request(self, pending: _PendingRequest, error: BaseException | None = None) -> None:
|
||||
with self._lock:
|
||||
if self._responses.get(pending.request_id) is pending.waiter:
|
||||
self._responses.pop(pending.request_id)
|
||||
if error is not None:
|
||||
pending.waiter.put_nowait(error)
|
||||
|
||||
def _wait_request(
|
||||
self,
|
||||
pending: _PendingRequest,
|
||||
*,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
notification_subscription: "NotificationSubscription | None" = None,
|
||||
) -> JsonValue:
|
||||
subscription = notification_subscription
|
||||
try:
|
||||
while True:
|
||||
if on_notification is not None and subscription is not None:
|
||||
subscription.drain(on_notification)
|
||||
try:
|
||||
item = pending.waiter.get_nowait()
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
wait_timeout = None
|
||||
if on_notification is not None:
|
||||
wait_timeout = 0.05
|
||||
if pending.deadline is not None:
|
||||
remaining = pending.deadline - time.monotonic()
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
with self._lock:
|
||||
self._responses.pop(request_id, None)
|
||||
diagnostics = self._runtime_diagnostics()
|
||||
suffix = f"\n{diagnostics}" if diagnostics else ""
|
||||
raise TimeoutError(
|
||||
f"{pending.method} timed out waiting for DeepSeek Harness runtime{suffix}"
|
||||
f"{method} timed out waiting for DeepSeek Harness runtime{suffix}"
|
||||
)
|
||||
wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining)
|
||||
try:
|
||||
item = pending.waiter.get(timeout=wait_timeout)
|
||||
item = waiter.get(timeout=wait_timeout)
|
||||
if on_notification is not None and subscription is not None:
|
||||
subscription.drain(on_notification)
|
||||
break
|
||||
except queue.Empty:
|
||||
continue
|
||||
if on_notification is not None and subscription is not None:
|
||||
subscription.drain(on_notification)
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
except BaseException:
|
||||
with self._lock:
|
||||
self._responses.pop(request_id, None)
|
||||
if temp_subscription is not None:
|
||||
temp_subscription.close()
|
||||
raise
|
||||
finally:
|
||||
self._discard_request(pending)
|
||||
if temp_subscription is not None:
|
||||
temp_subscription.close()
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def _write_message(self, message: JsonObject) -> None:
|
||||
proc = self._proc
|
||||
@@ -716,10 +683,6 @@ class NotificationSubscription:
|
||||
self._closed = True
|
||||
self._client._unsubscribe_notifications(self._subscription_id)
|
||||
|
||||
def _cancel(self, error: BaseException) -> None:
|
||||
self.close()
|
||||
self._notifications.put(error)
|
||||
|
||||
def next(self, timeout_seconds: float | None = None) -> Notification:
|
||||
"""Read one notification; an optional timeout stops only this local wait."""
|
||||
try:
|
||||
|
||||
@@ -178,7 +178,7 @@ for line in sys.stdin:
|
||||
"DEEPSEEK_BASE_URL": "http://127.0.0.1:4321",
|
||||
},
|
||||
) as harness:
|
||||
result = harness.run("say hello", session_id="main")
|
||||
result = harness.run([{"type": "text", "text": "say hello"}], session_id="main")
|
||||
|
||||
assert result.final_response == "hello from runtime"
|
||||
assert result.finish_reason == "max-tokens"
|
||||
@@ -252,7 +252,7 @@ for line in sys.stdin:
|
||||
) as harness:
|
||||
session = harness.start_session("main")
|
||||
result = session.run(
|
||||
"spawn a helper",
|
||||
[{"type": "text", "text": "spawn a helper"}],
|
||||
on_notification=lambda notification: seen.append(notification.method),
|
||||
)
|
||||
|
||||
@@ -292,7 +292,7 @@ for line in sys.stdin:
|
||||
SdkProtocolError,
|
||||
match=r"turn/end event requires a string data\.reason\.kind",
|
||||
):
|
||||
harness.run("reject malformed turn ending", session_id="main")
|
||||
harness.run([{"type": "text", "text": "reject malformed turn ending"}], session_id="main")
|
||||
|
||||
|
||||
def test_relative_cwd_is_absolute_in_process_environment_and_wire(
|
||||
@@ -363,7 +363,7 @@ for line in sys.stdin:
|
||||
_launch_args=(sys.executable, str(script)),
|
||||
cwd=str(tmp_path),
|
||||
) as harness:
|
||||
result = harness.run("spawn a helper", session_id="main")
|
||||
result = harness.run([{"type": "text", "text": "spawn a helper"}], session_id="main")
|
||||
|
||||
assert [notification.method for notification in result.notifications] == [
|
||||
"session.event",
|
||||
@@ -413,7 +413,7 @@ for line in sys.stdin:
|
||||
cwd=str(tmp_path),
|
||||
) as harness:
|
||||
result = harness.run(
|
||||
"delegate recursively",
|
||||
[{"type": "text", "text": "delegate recursively"}],
|
||||
session_id="main",
|
||||
on_notification=lambda notification: seen.append(notification.method),
|
||||
)
|
||||
@@ -467,7 +467,7 @@ for line in sys.stdin:
|
||||
_launch_args=(sys.executable, str(script)),
|
||||
cwd=str(tmp_path),
|
||||
) as harness:
|
||||
result = harness.run("stay in your lane", session_id="main")
|
||||
result = harness.run([{"type": "text", "text": "stay in your lane"}], session_id="main")
|
||||
|
||||
assert result.final_response == "right session"
|
||||
assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main"] * 4
|
||||
@@ -499,7 +499,7 @@ for line in sys.stdin:
|
||||
)
|
||||
|
||||
with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness:
|
||||
result = harness.run("one turn", session_id="main")
|
||||
result = harness.run([{"type": "text", "text": "one turn"}], session_id="main")
|
||||
assert harness.client._notifications.qsize() == 0
|
||||
|
||||
|
||||
@@ -539,8 +539,8 @@ for line in sys.stdin:
|
||||
)
|
||||
|
||||
with DeepSeekHarness(_launch_args=(sys.executable, str(script)), cwd=str(tmp_path)) as harness:
|
||||
first = harness.run("first turn", session_id="main")
|
||||
second = harness.run("second turn", session_id="main")
|
||||
first = harness.run([{"type": "text", "text": "first turn"}], session_id="main")
|
||||
second = harness.run([{"type": "text", "text": "second turn"}], session_id="main")
|
||||
|
||||
assert first.final_response == "first"
|
||||
assert second.final_response == "second"
|
||||
|
||||
@@ -129,9 +129,9 @@ def test_command_rejects_malformed_results(peer: Path, tmp_path: Path, response:
|
||||
sdk.client.session_command("main", "/compact")
|
||||
|
||||
|
||||
def test_run_keeps_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None:
|
||||
def test_content_blocks_keep_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None:
|
||||
with harness(peer, tmp_path, {"result": {}}) as sdk:
|
||||
result = sdk.start_session("main").run("/compact")
|
||||
result = sdk.start_session("main").run([{"type": "text", "text": "/compact"}])
|
||||
assert result.final_response == "ordinary prompt"
|
||||
assert result.finish_reason == "completed"
|
||||
sent = frames(tmp_path)
|
||||
|
||||
@@ -13,7 +13,7 @@ import shutil
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing, contextmanager
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -275,22 +275,6 @@ def _request_texts(request: dict) -> list[str]:
|
||||
return [text for message in request["messages"] for text in _text_blocks(message["content"])]
|
||||
|
||||
|
||||
def _receive_result(
|
||||
session: Session, response_claimed: threading.Event | None = None,
|
||||
) -> RunResult | CommandExecution:
|
||||
result: RunResult | CommandExecution | None = None
|
||||
with closing(session.receive_response()) as responses:
|
||||
if response_claimed is not None:
|
||||
response_claimed.set()
|
||||
for item in responses:
|
||||
assert result is None, "receive_response yielded another item after its terminal result"
|
||||
if not isinstance(item, Notification):
|
||||
assert isinstance(item, (RunResult, CommandExecution)), type(item)
|
||||
result = item
|
||||
assert result is not None, "receive_response ended without a terminal result"
|
||||
return result
|
||||
|
||||
|
||||
def _wait_command_done(next_notification: Callable[[], Notification]) -> dict:
|
||||
while True:
|
||||
notification = next_notification()
|
||||
@@ -306,10 +290,8 @@ def test_compact_without_history_returns_without_a_model_turn(tmp_path: Path) ->
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
observer = runtime.harness.client.subscribe_session_notifications(_SESSION_ID)
|
||||
try:
|
||||
assert runtime.call(session.query, " \n/压缩\t ", delivery="queue") is None
|
||||
# Wait independently for completion before starting the query's receiver.
|
||||
execution = runtime.call(session.run, " \n/压缩\t ", delivery="queue")
|
||||
done = runtime.call(_wait_command_done, observer.next)
|
||||
execution = runtime.call(_receive_result, session)
|
||||
except BaseException:
|
||||
# Wake a timed-out observer before unregistering its otherwise blocking queue.
|
||||
runtime.harness.close()
|
||||
@@ -332,20 +314,12 @@ def test_compact_rejects_busy_agent_before_the_model_is_released(tmp_path: Path)
|
||||
response = _Completion("DSH_COMMANDS_BUSY_REPLY", blocked=True)
|
||||
with _runtime(tmp_path, [response]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
sending = runtime.workers.submit(
|
||||
session.query, "Hold this request while compaction is attempted.", delivery="queue",
|
||||
running = runtime.workers.submit(
|
||||
session.run, "Hold this request while compaction is attempted.", delivery="queue",
|
||||
)
|
||||
assert response.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
assert sending.result(timeout=_WAIT_SECONDS) is None
|
||||
assert not response.release.is_set(), "query waited for the model before returning"
|
||||
|
||||
# Claim the prompt's FIFO slot before issuing the command for another receiver.
|
||||
receiving = threading.Event()
|
||||
running = runtime.workers.submit(_receive_result, session, receiving)
|
||||
assert receiving.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
assert not running.done()
|
||||
assert runtime.call(session.query, "/compact", delivery="queue") is None
|
||||
execution = runtime.call(_receive_result, session)
|
||||
assert not running.done() and not response.release.is_set()
|
||||
execution = runtime.call(session.run, "/compact", delivery="queue")
|
||||
assert isinstance(execution, CommandExecution)
|
||||
assert execution.result.kind == "error"
|
||||
assert not response.release.is_set()
|
||||
@@ -371,18 +345,13 @@ def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
# The replaced span is deliberately larger than the framed summary.
|
||||
prompt = _HISTORY_MARKER + "\n" + "Keep the original request only in the stored history. " * 256
|
||||
assert runtime.call(session.query, prompt, delivery="queue") is None
|
||||
first_result = runtime.call(_receive_result, session)
|
||||
first_result = runtime.call(session.run, prompt, delivery="queue")
|
||||
assert isinstance(first_result, RunResult)
|
||||
assert first_result.final_response == _FIRST_REPLY
|
||||
|
||||
sending = runtime.workers.submit(session.query, "/compact", delivery="queue")
|
||||
compacting = runtime.workers.submit(session.run, "/compact", delivery="queue")
|
||||
assert summary_response.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
assert sending.result(timeout=_WAIT_SECONDS) is None
|
||||
assert not summary_response.release.is_set(), "query waited for the summary before returning"
|
||||
receiving = threading.Event()
|
||||
compacting = runtime.workers.submit(_receive_result, session, receiving)
|
||||
assert receiving.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
assert not summary_response.release.is_set()
|
||||
assert not compacting.done(), "command returned before its summary request completed"
|
||||
summary_response.release.set()
|
||||
execution = compacting.result(timeout=_WAIT_SECONDS)
|
||||
@@ -415,8 +384,7 @@ def run_compaction_scenario(tmp_path: Path, *, capture_patch: Path | None = None
|
||||
assert "<compacted-summary>" in checkpoint_text and "</compacted-summary>" in checkpoint_text
|
||||
assert len(_of_type(checkpointed, "turn/start")) == len(_of_type(checkpointed, "turn/end")) == 1
|
||||
|
||||
assert runtime.call(session.query, _CONTINUE_PROMPT, delivery="queue") is None
|
||||
continued = runtime.call(_receive_result, session)
|
||||
continued = runtime.call(session.run, _CONTINUE_PROMPT, delivery="queue")
|
||||
assert isinstance(continued, RunResult)
|
||||
assert continued.final_response == _CONTINUE_REPLY
|
||||
first, compaction, following = runtime.model.captured()
|
||||
@@ -452,8 +420,7 @@ def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_pa
|
||||
with _runtime(tmp_path, [_Completion(text) for text in replies]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
for prompt, reply in zip(prompts, replies):
|
||||
assert runtime.call(session.query, prompt, delivery="queue") is None
|
||||
result = runtime.call(_receive_result, session)
|
||||
result = runtime.call(session.run, prompt, delivery="queue")
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == reply
|
||||
requests = runtime.model.captured()
|
||||
@@ -473,13 +440,11 @@ def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_p
|
||||
reply = "DSH_AFTER_PLAN_TEXT_REPLY"
|
||||
with _runtime(tmp_path, [_Completion(reply_before), _Completion(reply)]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
assert runtime.call(session.query, input_text, delivery="queue") is None
|
||||
first = runtime.call(_receive_result, session)
|
||||
first = runtime.call(session.run, input_text, delivery="queue")
|
||||
assert isinstance(first, RunResult)
|
||||
assert first.final_response == reply_before
|
||||
|
||||
assert runtime.call(session.query, following, delivery="queue") is None
|
||||
result = runtime.call(_receive_result, session)
|
||||
result = runtime.call(session.run, following, delivery="queue")
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == reply
|
||||
initial, continued = runtime.model.captured()
|
||||
@@ -510,8 +475,7 @@ def _permission_facts(events: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def _permission_command(runtime: _Runtime, session: Session, preset: str) -> CommandExecution:
|
||||
assert runtime.call(session.query, f"/permission {preset}") is None
|
||||
result = runtime.call(_receive_result, session)
|
||||
result = runtime.call(session.run, f"/permission {preset}")
|
||||
assert isinstance(result, CommandExecution)
|
||||
return result
|
||||
|
||||
@@ -524,8 +488,7 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti
|
||||
initial_config = dict(runtime.harness.config.env)
|
||||
for session, text in [(main, "Initial main policy; do not use tools."),
|
||||
(other, "Initial other policy; do not use tools.")]:
|
||||
runtime.call(session.query, text)
|
||||
assert isinstance(runtime.call(_receive_result, session), RunResult)
|
||||
assert isinstance(runtime.call(session.run, text), RunResult)
|
||||
|
||||
readonly = _permission_command(runtime, main, "read-only")
|
||||
invalid = _permission_command(runtime, main, "no-such-preset")
|
||||
@@ -535,25 +498,20 @@ def test_permission_changes_only_the_selected_session_and_retains_prompt_semanti
|
||||
assert "unknown preset" in invalid.result.text
|
||||
assert len(runtime.model.captured()) == 2, "idle permission commands woke the model"
|
||||
|
||||
runtime.call(main.query, "Read-only policy after a plain query; do not use tools.")
|
||||
assert isinstance(runtime.call(_receive_result, main), RunResult)
|
||||
assert isinstance(runtime.call(main.run, "Read-only policy after a plain query; do not use tools."), RunResult)
|
||||
full = _permission_command(runtime, main, "danger-full-access")
|
||||
assert full.result.kind == "success"
|
||||
assert len(runtime.model.captured()) == 3
|
||||
runtime.call(main.query, "Full policy after a plain query; do not use tools.")
|
||||
assert isinstance(runtime.call(_receive_result, main), RunResult)
|
||||
assert isinstance(runtime.call(main.run, "Full policy after a plain query; do not use tools."), RunResult)
|
||||
restored = _permission_command(runtime, main, "workspace-write")
|
||||
assert restored.result.kind == "success"
|
||||
runtime.call(main.query, "Switched-back policy; do not use tools.")
|
||||
assert isinstance(runtime.call(_receive_result, main), RunResult)
|
||||
assert isinstance(runtime.call(main.run, "Switched-back policy; do not use tools."), RunResult)
|
||||
|
||||
for session in [other, runtime.harness.start_session("permission-new")]:
|
||||
runtime.call(session.query, "Report your own policy; do not use tools.")
|
||||
assert isinstance(runtime.call(_receive_result, session), RunResult)
|
||||
ordinary = runtime.call(main.run, "/permission danger-full-access")
|
||||
assert isinstance(runtime.call(session.run, "Report your own policy; do not use tools."), RunResult)
|
||||
ordinary = runtime.call(main.run, [{"type": "text", "text": "/permission danger-full-access"}])
|
||||
assert isinstance(ordinary, RunResult)
|
||||
runtime.call(main.query, "/permission")
|
||||
assert isinstance(runtime.call(_receive_result, main), RunResult)
|
||||
assert isinstance(runtime.call(main.run, "/permission"), RunResult)
|
||||
assert runtime.harness.config.env == initial_config
|
||||
|
||||
requests = runtime.model.captured()
|
||||
@@ -596,11 +554,8 @@ def test_running_permission_change_keeps_the_native_extra_step_and_notifications
|
||||
second = _Completion("PERMISSION_SWITCH_SEEN")
|
||||
with _runtime(tmp_path, [first, second], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
runtime.call(session.query, "Wait for the policy change, then finish without tools.")
|
||||
observing = runtime.workers.submit(session.run, "Wait for the policy change, then finish without tools.")
|
||||
assert first.entered.wait(_WAIT_SECONDS), runtime.model.failures
|
||||
claimed = threading.Event()
|
||||
observing = runtime.workers.submit(_receive_result, session, claimed)
|
||||
assert claimed.wait(_WAIT_SECONDS)
|
||||
execution = _permission_command(runtime, session, "danger-full-access")
|
||||
assert execution.result.kind == "success"
|
||||
assert not first.release.is_set()
|
||||
@@ -639,8 +594,7 @@ def test_resumed_permission_survives_different_launch_defaults(tmp_path: Path) -
|
||||
saved = _permission_facts(_read_events(runtime.logs))
|
||||
with _runtime(tmp_path, [_Completion("RESUMED_PERMISSION")], initial_permission="danger-full-access") as resumed:
|
||||
session = resumed.call(resumed.harness.resume_session, _SESSION_ID)
|
||||
resumed.call(session.query, "Report the restored policy without using tools.")
|
||||
assert isinstance(resumed.call(_receive_result, session), RunResult)
|
||||
assert isinstance(resumed.call(session.run, "Report the restored policy without using tools."), RunResult)
|
||||
[request] = resumed.model.captured()
|
||||
_assert_request_permission(request, "read-only", "ask")
|
||||
assert resumed.harness.config.env["DSH_PERMISSION_MODE"] == "danger-full-access"
|
||||
|
||||
@@ -55,7 +55,7 @@ for line in sys.stdin:
|
||||
_launch_args=(sys.executable, str(script)), cwd=str(tmp_path),
|
||||
env={"MAIN_RESPONSE": expected["finalResponse"], "AUDIT_EVENTS": json.dumps(expected["auditEvents"])},
|
||||
) as harness:
|
||||
result = harness.run("Keep the original response.", session_id="sdk-audit")
|
||||
result = harness.run([{"type": "text", "text": "Keep the original response."}], session_id="sdk-audit")
|
||||
audit_notifications = [notification for notification in result.notifications
|
||||
if notification.method == "session.event"
|
||||
and notification.payload["event"]["type"] in ("plugin:translator/request", "plugin:translator/result")]
|
||||
|
||||
@@ -4,21 +4,23 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import CommandExecution, RunResult, Session
|
||||
from deepseek_harness.client import NotificationSubscription
|
||||
from test_commands_runtime import (
|
||||
_Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission,
|
||||
_command_pair, _of_type, _permission_facts, _read_events, _receive_result,
|
||||
_command_pair, _of_type, _permission_facts, _read_events,
|
||||
_request_texts, _runtime, _text_blocks,
|
||||
)
|
||||
from test_plan_runtime import _wait_activity
|
||||
from test_plan_runtime import _WaitResult, _wait_activity
|
||||
|
||||
|
||||
def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution:
|
||||
runtime.call(session.query, text)
|
||||
result = runtime.call(_receive_result, session)
|
||||
result = runtime.call(session.run, text)
|
||||
assert isinstance(result, CommandExecution)
|
||||
assert result.result.kind == kind, result.result.text
|
||||
return result
|
||||
@@ -63,7 +65,7 @@ def test_goal_reads_and_invalid_controls_do_not_start_model_work(tmp_path: Path)
|
||||
_command_pair(events, execution, "goal")
|
||||
|
||||
|
||||
def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path) -> None:
|
||||
def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
final = _Completion("GOAL_DONE", blocked=True)
|
||||
with _runtime(tmp_path, [_Completion("PROGRESS"), *_completion_steps(final)],
|
||||
initial_permission="workspace-write", max_goal_rounds=3) as runtime:
|
||||
@@ -73,13 +75,35 @@ def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_pa
|
||||
completed = _until(events, "goal/change", "complete")
|
||||
assert completed["goal"]["phase"] == "complete" and completed["roundsStarted"] == 2
|
||||
assert final.entered.wait(_WAIT_SECONDS)
|
||||
waiting = runtime.harness.client._begin_request("session/wait", {"sessionId": session.id})
|
||||
# A later RPC confirms admission on the same pipe; keep the wait's
|
||||
# response queued so worker scheduling cannot hide an early return.
|
||||
runtime.call(runtime.harness.client.get_working_directory, session.id)
|
||||
assert waiting.waiter.empty() and not final.release.is_set()
|
||||
client = runtime.harness.client
|
||||
sent = threading.Event()
|
||||
replied = threading.Event()
|
||||
wait_ids = set()
|
||||
write = client._write_message
|
||||
handle = client._handle_message
|
||||
|
||||
def record_send(message: dict) -> None:
|
||||
is_wait = message.get("method") == "session/wait"
|
||||
if is_wait:
|
||||
wait_ids.add(message["id"])
|
||||
write(message)
|
||||
if is_wait:
|
||||
sent.set()
|
||||
|
||||
def record_response(message: object) -> None:
|
||||
if isinstance(message, dict) and "method" not in message and message.get("id") in wait_ids:
|
||||
replied.set()
|
||||
handle(message)
|
||||
|
||||
monkeypatch.setattr(client, "_write_message", record_send)
|
||||
monkeypatch.setattr(client, "_handle_message", record_response)
|
||||
waiting = runtime.workers.submit(client.request, "session/wait", {"sessionId": session.id}, response_model=_WaitResult)
|
||||
assert sent.wait(_WAIT_SECONDS)
|
||||
# Observe the reader directly so caller scheduling cannot hide an early response.
|
||||
runtime.call(client.get_working_directory, session.id)
|
||||
assert not replied.is_set() and not final.release.is_set()
|
||||
final.release.set()
|
||||
assert runtime.call(runtime.harness.client._wait_request, waiting) == {}
|
||||
assert waiting.result(timeout=_WAIT_SECONDS).model_dump() == {}
|
||||
assert _until(events, "turn/end")["turn"] == 2
|
||||
requests = runtime.model.captured()
|
||||
assert len(requests) == 4
|
||||
@@ -121,7 +145,7 @@ def test_pause_aborts_then_edit_and_resume_use_revised_goal_without_changing_oth
|
||||
assert 'Objective: "Revised objective"' in "\n".join(_request_texts(runtime.model.captured()[1]))
|
||||
_command(runtime, session, "/goal clear")
|
||||
_command(runtime, session, "/goal clear")
|
||||
result = runtime.call(session.run, "/goal Plain text objective")
|
||||
result = runtime.call(session.run, [{"type": "text", "text": "/goal Plain text objective"}])
|
||||
assert isinstance(result, RunResult) and result.final_response == "RAW_GOAL_TEXT"
|
||||
assert "/goal Plain text objective" in _request_texts(runtime.model.captured()[-1])
|
||||
changes = _of_type(_read_events(runtime.logs), "goal/change")
|
||||
|
||||
@@ -11,7 +11,7 @@ from pydantic import BaseModel
|
||||
from deepseek_harness import CommandExecution, RunResult, Session, UserQuestionRequest
|
||||
from test_commands_runtime import (
|
||||
_Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission,
|
||||
_command_pair, _of_type, _permission_facts, _read_events, _receive_result,
|
||||
_command_pair, _of_type, _permission_facts, _read_events,
|
||||
_request_texts, _runtime, _text_blocks,
|
||||
)
|
||||
|
||||
@@ -25,8 +25,7 @@ class _WaitResult(BaseModel):
|
||||
|
||||
|
||||
def _command(runtime: _Runtime, session: Session, text: str) -> CommandExecution:
|
||||
assert runtime.call(session.query, text) is None
|
||||
result = runtime.call(_receive_result, session)
|
||||
result = runtime.call(session.run, text)
|
||||
assert isinstance(result, CommandExecution)
|
||||
assert result.result.kind == "success", result.result.text
|
||||
return result
|
||||
@@ -94,8 +93,7 @@ def test_plan_body_uses_native_input_then_off_removes_guidance_but_keeps_exit_to
|
||||
assert {"exit_plan_mode", "write", "edit"} <= _tool_names(first)
|
||||
_assert_request_permission(first, "workspace-write", "ask")
|
||||
_command(runtime, session, "/plan off")
|
||||
runtime.call(session.query, "Continue after the user changed the mode.")
|
||||
result = runtime.call(_receive_result, session)
|
||||
result = runtime.call(session.run, "Continue after the user changed the mode.")
|
||||
assert isinstance(result, RunResult) and result.final_response == "AFTER_OFF"
|
||||
second = runtime.model.captured()[1]
|
||||
assert _GUIDANCE not in _effective_system(second)
|
||||
@@ -112,11 +110,8 @@ def test_plan_body_steers_the_next_step_of_a_running_turn(tmp_path: Path) -> Non
|
||||
first = _Completion("FIRST_REPLY", blocked=True)
|
||||
with _runtime(tmp_path, [first, _Completion("PLANNING_REPLY")], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
runtime.call(session.query, "Start this task without tools.")
|
||||
observing = runtime.workers.submit(session.run, "Start this task without tools.")
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
claimed = threading.Event()
|
||||
observing = runtime.workers.submit(_receive_result, session, claimed)
|
||||
assert claimed.wait(_WAIT_SECONDS)
|
||||
execution = _command(runtime, session, "/plan Design a smaller change.")
|
||||
assert not first.release.is_set() and not observing.done()
|
||||
first.release.set()
|
||||
@@ -138,11 +133,8 @@ def test_running_bare_selection_does_not_force_a_step_and_can_be_cancelled(tmp_p
|
||||
first = _Completion("FIRST_REPLY", blocked=True)
|
||||
with _runtime(tmp_path, [first, _Completion("NEXT_REPLY")], initial_permission="workspace-write") as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
runtime.call(session.query, "Start without tools.")
|
||||
observing = runtime.workers.submit(session.run, "Start without tools.")
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
claimed = threading.Event()
|
||||
observing = runtime.workers.submit(_receive_result, session, claimed)
|
||||
assert claimed.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/plan")
|
||||
if cancel:
|
||||
cancelled = _command(runtime, session, "/plan off")
|
||||
@@ -151,8 +143,7 @@ def test_running_bare_selection_does_not_force_a_step_and_can_be_cancelled(tmp_p
|
||||
first.release.set()
|
||||
assert isinstance(observing.result(timeout=_WAIT_SECONDS), RunResult)
|
||||
assert len(runtime.model.captured()) == 1
|
||||
runtime.call(session.query, "Inspect the next task without tools.")
|
||||
assert isinstance(runtime.call(_receive_result, session), RunResult)
|
||||
assert isinstance(runtime.call(session.run, "Inspect the next task without tools."), RunResult)
|
||||
assert (_GUIDANCE in _effective_system(runtime.model.captured()[1])) is (not cancel)
|
||||
events = _read_events(runtime.logs)
|
||||
assert len(_of_type(events, "turn/start")) == 2
|
||||
@@ -225,8 +216,7 @@ def test_plan_state_survives_resume_without_changing_permissions(tmp_path: Path)
|
||||
_command(runtime, session, "/plan")
|
||||
with _runtime(tmp_path, [_Completion("RESTORED_PLAN")], initial_permission="workspace-write") as resumed:
|
||||
session = resumed.call(resumed.harness.resume_session, _SESSION_ID)
|
||||
resumed.call(session.query, "Continue planning without tools.")
|
||||
assert isinstance(resumed.call(_receive_result, session), RunResult)
|
||||
assert isinstance(resumed.call(session.run, "Continue planning without tools."), RunResult)
|
||||
[request] = resumed.model.captured()
|
||||
assert _GUIDANCE in _effective_system(request)
|
||||
_assert_request_permission(request, "workspace-write", "ask")
|
||||
@@ -240,11 +230,9 @@ def test_plan_state_is_local_and_run_keeps_ordinary_text_semantics(tmp_path: Pat
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
_command(runtime, session, "/plan")
|
||||
other = runtime.harness.start_session("plan-other")
|
||||
runtime.call(other.query, "Inspect your own task without tools.")
|
||||
assert isinstance(runtime.call(_receive_result, other), RunResult)
|
||||
runtime.call(session.query, "Inspect the plan without tools.")
|
||||
assert isinstance(runtime.call(_receive_result, session), RunResult)
|
||||
result = runtime.call(session.run, "/plan off")
|
||||
assert isinstance(runtime.call(other.run, "Inspect your own task without tools."), RunResult)
|
||||
assert isinstance(runtime.call(session.run, "Inspect the plan without tools."), RunResult)
|
||||
result = runtime.call(session.run, [{"type": "text", "text": "/plan off"}])
|
||||
assert isinstance(result, RunResult)
|
||||
first, second, third = runtime.model.captured()
|
||||
assert _GUIDANCE not in _effective_system(first)
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
"""Split query submission and per-request response observation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, SdkProtocolError
|
||||
from deepseek_harness.errors import JsonRpcError, TransportClosedError
|
||||
|
||||
|
||||
class Ack(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def peer(tmp_path: Path) -> Path:
|
||||
script = tmp_path / "queries.py"
|
||||
script.write_text('''import json
|
||||
import sys
|
||||
|
||||
serial = 0
|
||||
pending_commands = []
|
||||
pending_prompts = []
|
||||
|
||||
def send(frame):
|
||||
print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True)
|
||||
|
||||
def event(session, type, data):
|
||||
send({"method": "session.event", "params": {"sessionId": session, "event": {"type": type, "data": data}}})
|
||||
|
||||
def command(frame, number):
|
||||
session = frame["params"]["sessionId"]
|
||||
identity = "command-" + str(number)
|
||||
event(session, "command/run", {"commandId": identity})
|
||||
event(session, "command/done", {"commandId": identity, "kind": "success"})
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": identity, "result": {"kind": "success", "text": "compacted"}}}})
|
||||
|
||||
def receipt(frame, number):
|
||||
session = frame["params"]["sessionId"]
|
||||
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
|
||||
event(session, "agent/inbox/spliced", {"inserted": [{"id": "input-" + str(number)}]})
|
||||
|
||||
def finish(frame):
|
||||
session = frame["params"]["sessionId"]
|
||||
event(session, "assistant/message", {"message": {"content": [
|
||||
{"type": "text", "text": "answer for " + frame["params"]["text"]}]}})
|
||||
event(session, "turn/end", {"turn": 1, "reason": {"kind": "completed"}})
|
||||
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
|
||||
|
||||
for line in sys.stdin:
|
||||
frame = json.loads(line)
|
||||
method = frame["method"]
|
||||
if method == "initialize":
|
||||
send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}})
|
||||
elif method == "session/query":
|
||||
serial += 1
|
||||
params = frame["params"]
|
||||
assert set(params) == {"sessionId", "text", "delivery"}
|
||||
assert params["delivery"] in ("queue", "steer")
|
||||
text = params["text"]
|
||||
if text == "hold-command":
|
||||
pending_commands.append((frame, serial))
|
||||
elif text == "/compact":
|
||||
command(frame, serial)
|
||||
elif text == "silent-command":
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": "silent", "result": {"kind": "success"}}}})
|
||||
elif text == "rpc-error":
|
||||
send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}})
|
||||
elif text == "bad-route":
|
||||
send({"id": frame["id"], "result": {"kind": "unknown"}})
|
||||
elif text == "bad-execution":
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {"commandId": "c"}}})
|
||||
else:
|
||||
result = {"kind": "prompt", "messageId": "input-" + str(serial)}
|
||||
if text == "fast-prompt":
|
||||
receipt(frame, serial)
|
||||
finish(frame)
|
||||
send({"id": frame["id"], "result": result})
|
||||
else:
|
||||
send({"id": frame["id"], "result": result})
|
||||
receipt(frame, serial)
|
||||
if text == "hold-prompt":
|
||||
pending_prompts.append(frame)
|
||||
else:
|
||||
finish(frame)
|
||||
elif method == "release":
|
||||
for original, number in pending_commands:
|
||||
command(original, number)
|
||||
pending_commands.clear()
|
||||
for original in pending_prompts:
|
||||
finish(original)
|
||||
pending_prompts.clear()
|
||||
send({"id": frame["id"], "result": {}})
|
||||
elif method == "barrier":
|
||||
send({"id": frame["id"], "result": {}})
|
||||
elif method == "disconnect":
|
||||
send({"id": frame["id"], "result": {}})
|
||||
break
|
||||
elif method == "shutdown":
|
||||
send({"id": frame["id"], "result": {}})
|
||||
break
|
||||
else:
|
||||
raise AssertionError(method)
|
||||
''', encoding="utf-8")
|
||||
return script
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sdk(peer: Path, tmp_path: Path):
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
|
||||
_launch_args=(sys.executable, str(peer)),
|
||||
) as harness:
|
||||
yield harness
|
||||
|
||||
|
||||
def release(sdk: DeepSeekHarness) -> None:
|
||||
sdk.client.request("release", {}, response_model=Ack, timeout_seconds=5)
|
||||
|
||||
|
||||
def assert_released(sdk: DeepSeekHarness) -> None:
|
||||
assert sdk.client._responses == {}
|
||||
assert sdk.client._notification_subscribers == {}
|
||||
|
||||
|
||||
def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
assert session.query("hold-command") is None
|
||||
assert len(sdk.client._responses) == 1
|
||||
release(sdk)
|
||||
responses = list(session.receive_response())
|
||||
assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"]
|
||||
assert isinstance(responses[-1], CommandExecution)
|
||||
assert responses[-1].command_id == "command-1"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", "/permission"])
|
||||
@pytest.mark.parametrize("delivery", ["queue", "steer"])
|
||||
def test_prompt_yields_receipt_through_idle_then_run_result(sdk: DeepSeekHarness, text: str, delivery: str) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query(text, delivery=delivery)
|
||||
responses = list(session.receive_response())
|
||||
assert all(isinstance(item, Notification) for item in responses[:-1])
|
||||
assert responses[0].payload["event"]["type"] == "agent/inbox/spliced"
|
||||
result = responses[-1]
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "answer for " + text
|
||||
assert result.finish_reason == "completed"
|
||||
assert result.notifications == responses[:-1]
|
||||
assert len(result.events) == 3
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_late_receive_uses_an_arrived_response_before_expired_deadline(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
sdk.client.config.request_timeout_seconds = 0
|
||||
session.query("/compact")
|
||||
sdk.client.request("barrier", {}, response_model=Ack, timeout_seconds=5)
|
||||
assert isinstance(list(session.receive_response())[-1], CommandExecution)
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_pending_response_timeout_releases_only_its_observer(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
sdk.client.config.request_timeout_seconds = 0
|
||||
session.query("hold-command")
|
||||
with pytest.raises(TimeoutError, match="session/query timed out"):
|
||||
list(session.receive_response())
|
||||
assert_released(sdk)
|
||||
release(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,error", [
|
||||
("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError),
|
||||
])
|
||||
def test_query_errors_arrive_during_receive_and_leave_later_queries_usable(sdk: DeepSeekHarness, text: str, error: type) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query(text)
|
||||
with pytest.raises(error):
|
||||
list(session.receive_response())
|
||||
assert_released(sdk)
|
||||
session.query("/compact")
|
||||
assert isinstance(list(session.receive_response())[-1], CommandExecution)
|
||||
|
||||
|
||||
def test_receivers_claim_fifo_even_when_responses_arrive_out_of_order(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("hold-command")
|
||||
session.query("/compact")
|
||||
release(sdk)
|
||||
first = session.receive_response()
|
||||
second = session.receive_response()
|
||||
assert list(second)[-1].command_id == "command-2"
|
||||
assert list(first)[-1].command_id == "command-1"
|
||||
assert_released(sdk)
|
||||
with pytest.raises(RuntimeError, match="preceding query"):
|
||||
session.receive_response()
|
||||
|
||||
|
||||
def test_command_receiver_stops_at_its_done_before_a_later_prompt(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("/compact")
|
||||
session.query("ordinary")
|
||||
sdk.client.request("barrier", {}, response_model=Ack)
|
||||
command = list(session.receive_response())
|
||||
assert [item.payload["event"]["type"] for item in command[:-1]] == ["command/run", "command/done"]
|
||||
assert command[-1].command_id == "command-1"
|
||||
prompt = list(session.receive_response())
|
||||
assert isinstance(prompt[-1], RunResult)
|
||||
assert prompt[-1].final_response == "answer for ordinary"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_command_result_without_notifications_does_not_wait_for_done(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("silent-command")
|
||||
responses = list(session.receive_response())
|
||||
assert len(responses) == 1
|
||||
assert responses[0].command_id == "silent"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["/compact", "silent-command"])
|
||||
def test_late_command_receive_preserves_completed_result_after_eof(sdk: DeepSeekHarness, text: str) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query(text)
|
||||
sdk.client.request("disconnect", {}, response_model=Ack)
|
||||
sdk.client._reader_thread.join(5)
|
||||
assert not sdk.client._reader_thread.is_alive()
|
||||
responses = list(session.receive_response())
|
||||
assert isinstance(responses[-1], CommandExecution)
|
||||
assert responses[-1].result.kind == "success"
|
||||
if text == "/compact":
|
||||
assert [item.payload["event"]["type"] for item in responses[:-1]] == ["command/run", "command/done"]
|
||||
else:
|
||||
assert len(responses) == 1
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_command_receive_does_not_hide_other_subscription_errors(sdk: DeepSeekHarness, monkeypatch) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("/compact")
|
||||
sdk.client.request("barrier", {}, response_model=Ack)
|
||||
receiver = session.receive_response()
|
||||
|
||||
def fail():
|
||||
raise SdkProtocolError("invalid notification")
|
||||
|
||||
monkeypatch.setattr(receiver._subscription, "_next_queued", fail)
|
||||
with pytest.raises(SdkProtocolError, match="invalid notification"):
|
||||
list(receiver)
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_query_and_another_receiver_can_run_while_a_prompt_receiver_waits(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("hold-prompt")
|
||||
receiver = session.receive_response()
|
||||
entered = threading.Event()
|
||||
responses = []
|
||||
errors = []
|
||||
|
||||
def receive() -> None:
|
||||
try:
|
||||
for item in receiver:
|
||||
responses.append(item)
|
||||
entered.set()
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
thread = threading.Thread(target=receive)
|
||||
thread.start()
|
||||
try:
|
||||
assert entered.wait(5)
|
||||
session.query("/compact")
|
||||
assert list(session.receive_response())[-1].command_id == "command-2"
|
||||
assert thread.is_alive()
|
||||
release(sdk)
|
||||
thread.join(5)
|
||||
assert not thread.is_alive()
|
||||
assert not errors
|
||||
assert isinstance(responses[-1], RunResult)
|
||||
assert responses[-1].final_response == "answer for hold-prompt"
|
||||
assert_released(sdk)
|
||||
finally:
|
||||
receiver.close()
|
||||
thread.join(5)
|
||||
|
||||
|
||||
def test_iterator_close_before_first_next_releases_request_and_subscription(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("hold-command")
|
||||
receiver = session.receive_response()
|
||||
receiver.close()
|
||||
receiver.close()
|
||||
assert list(receiver) == []
|
||||
assert_released(sdk)
|
||||
release(sdk)
|
||||
session.query("/compact")
|
||||
assert list(session.receive_response())[-1].command_id == "command-2"
|
||||
|
||||
|
||||
def test_iterator_close_after_a_prompt_notification_releases_its_subscription(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("hold-prompt")
|
||||
receiver = session.receive_response()
|
||||
assert isinstance(next(receiver), Notification)
|
||||
receiver.close()
|
||||
assert list(receiver) == []
|
||||
assert_released(sdk)
|
||||
release(sdk)
|
||||
|
||||
|
||||
def test_iterator_close_wakes_its_blocked_request_receiver(sdk: DeepSeekHarness, monkeypatch) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("hold-command")
|
||||
receiver = session.receive_response()
|
||||
entered = threading.Event()
|
||||
errors = []
|
||||
original = sdk.client._wait_request
|
||||
|
||||
def wait(request, **kwargs):
|
||||
entered.set()
|
||||
return original(request, **kwargs)
|
||||
|
||||
monkeypatch.setattr(sdk.client, "_wait_request", wait)
|
||||
|
||||
def receive() -> None:
|
||||
try:
|
||||
assert list(receiver) == []
|
||||
except BaseException as error:
|
||||
errors.append(error)
|
||||
|
||||
thread = threading.Thread(target=receive)
|
||||
thread.start()
|
||||
try:
|
||||
assert entered.wait(5)
|
||||
receiver.close()
|
||||
thread.join(5)
|
||||
assert not thread.is_alive()
|
||||
assert not errors
|
||||
assert_released(sdk)
|
||||
finally:
|
||||
receiver.close()
|
||||
thread.join(5)
|
||||
|
||||
|
||||
def test_terminal_result_releases_resources_before_caller_sends_another_query(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("/compact")
|
||||
receiver = session.receive_response()
|
||||
for item in receiver:
|
||||
if isinstance(item, CommandExecution):
|
||||
assert_released(sdk)
|
||||
session.query("/compact")
|
||||
receiver.close()
|
||||
assert list(session.receive_response())[-1].command_id == "command-2"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_runtime_close_settles_unreceived_queries(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
session.query("hold-command")
|
||||
sdk.close()
|
||||
with pytest.raises(TransportClosedError):
|
||||
list(session.receive_response())
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_send_failure_releases_subscription_without_queueing_a_receiver(sdk: DeepSeekHarness, monkeypatch) -> None:
|
||||
session = sdk.start_session("main")
|
||||
original = sdk.client._write_message
|
||||
|
||||
def write(message):
|
||||
if message.get("method") == "session/query":
|
||||
raise TransportClosedError("write failed")
|
||||
original(message)
|
||||
|
||||
monkeypatch.setattr(sdk.client, "_write_message", write)
|
||||
with pytest.raises(TransportClosedError, match="write failed"):
|
||||
session.query("ordinary")
|
||||
assert_released(sdk)
|
||||
with pytest.raises(RuntimeError, match="preceding query"):
|
||||
session.receive_response()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Unified blocking input, native command results and independent run observation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness, Notification, RunResult, SdkProtocolError
|
||||
from deepseek_harness.errors import JsonRpcError
|
||||
|
||||
|
||||
class Ack(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def peer(tmp_path: Path) -> Path:
|
||||
script = tmp_path / "queries.py"
|
||||
script.write_text('''import json
|
||||
import sys
|
||||
|
||||
serial = 0
|
||||
pending_commands = []
|
||||
pending_prompts = []
|
||||
|
||||
def send(frame):
|
||||
print(json.dumps({"jsonrpc": "2.0", **frame}), flush=True)
|
||||
|
||||
def event(session, type, data):
|
||||
send({"method": "session.event", "params": {"sessionId": session, "event": {"type": type, "data": data}}})
|
||||
|
||||
def command(frame, number):
|
||||
session = frame["params"]["sessionId"]
|
||||
identity = "command-" + str(number)
|
||||
event(session, "command/run", {"commandId": identity})
|
||||
event(session, "command/done", {"commandId": identity, "kind": "success"})
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": identity, "result": {"kind": "success", "text": "compacted"}}}})
|
||||
|
||||
def receipt(frame, number):
|
||||
session = frame["params"]["sessionId"]
|
||||
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
|
||||
event(session, "agent/inbox/spliced", {"inserted": [{"id": "input-" + str(number)}]})
|
||||
|
||||
def finish(frame):
|
||||
session = frame["params"]["sessionId"]
|
||||
event(session, "assistant/message", {"message": {"content": [
|
||||
{"type": "text", "text": "answer for " + frame["params"]["text"]}]}})
|
||||
event(session, "turn/end", {"turn": 1, "reason": {"kind": "completed"}})
|
||||
send({"method": "session.status", "params": {"sessionId": session, "status": "idle"}})
|
||||
|
||||
for line in sys.stdin:
|
||||
frame = json.loads(line)
|
||||
method = frame["method"]
|
||||
if method == "initialize":
|
||||
send({"id": frame["id"], "result": {"serverInfo": {"name": "query-peer"}}})
|
||||
elif method == "session/query":
|
||||
serial += 1
|
||||
params = frame["params"]
|
||||
assert set(params) == {"sessionId", "text", "delivery"}
|
||||
assert params["delivery"] in ("queue", "steer")
|
||||
text = params["text"]
|
||||
if text == "hold-command":
|
||||
pending_commands.append((frame, serial))
|
||||
elif text == "/compact":
|
||||
command(frame, serial)
|
||||
elif text == "silent-command":
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {
|
||||
"commandId": "silent", "result": {"kind": "success"}}}})
|
||||
elif text == "rpc-error":
|
||||
send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}})
|
||||
elif text == "bad-route":
|
||||
send({"id": frame["id"], "result": {"kind": "unknown"}})
|
||||
elif text == "bad-execution":
|
||||
send({"id": frame["id"], "result": {"kind": "command", "execution": {"commandId": "c"}}})
|
||||
else:
|
||||
result = {"kind": "prompt", "messageId": "input-" + str(serial)}
|
||||
if text == "fast-prompt":
|
||||
receipt(frame, serial)
|
||||
finish(frame)
|
||||
send({"id": frame["id"], "result": result})
|
||||
else:
|
||||
send({"id": frame["id"], "result": result})
|
||||
receipt(frame, serial)
|
||||
if text == "hold-prompt":
|
||||
pending_prompts.append(frame)
|
||||
else:
|
||||
finish(frame)
|
||||
elif method == "release":
|
||||
for original, number in pending_commands:
|
||||
command(original, number)
|
||||
pending_commands.clear()
|
||||
for original in pending_prompts:
|
||||
finish(original)
|
||||
pending_prompts.clear()
|
||||
send({"id": frame["id"], "result": {}})
|
||||
elif method == "barrier":
|
||||
send({"id": frame["id"], "result": {}})
|
||||
elif method == "disconnect":
|
||||
send({"id": frame["id"], "result": {}})
|
||||
break
|
||||
elif method == "shutdown":
|
||||
send({"id": frame["id"], "result": {}})
|
||||
break
|
||||
else:
|
||||
raise AssertionError(method)
|
||||
''', encoding="utf-8")
|
||||
return script
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sdk(peer: Path, tmp_path: Path):
|
||||
with DeepSeekHarness(
|
||||
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
|
||||
_launch_args=(sys.executable, str(peer)),
|
||||
) as harness:
|
||||
yield harness
|
||||
|
||||
|
||||
def release(sdk: DeepSeekHarness) -> None:
|
||||
sdk.client.request("release", {}, response_model=Ack, timeout_seconds=5)
|
||||
|
||||
|
||||
def assert_released(sdk: DeepSeekHarness) -> None:
|
||||
assert sdk.client._responses == {}
|
||||
assert sdk.client._notification_subscribers == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["session", "harness"])
|
||||
def test_run_returns_command_without_waiting_for_idle(sdk: DeepSeekHarness, entry: str) -> None:
|
||||
notifications = []
|
||||
result = (sdk.run("/compact", session_id="main", on_notification=notifications.append)
|
||||
if entry == "harness" else sdk.start_session("main").run("/compact", on_notification=notifications.append))
|
||||
assert isinstance(result, CommandExecution)
|
||||
assert result.result.kind == "success"
|
||||
assert [notice.payload["event"]["type"] for notice in notifications] == ["command/run", "command/done"]
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", " /compact extra "])
|
||||
def test_run_preserves_ordinary_text_and_result(sdk: DeepSeekHarness, text: str) -> None:
|
||||
notifications = []
|
||||
result = sdk.start_session("main").run(text, on_notification=notifications.append)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "answer for " + text
|
||||
assert result.finish_reason == "completed"
|
||||
assert result.notifications == notifications
|
||||
assert notifications[0].payload["event"]["type"] == "agent/inbox/spliced"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_run_accepts_steer_for_text(sdk: DeepSeekHarness) -> None:
|
||||
result = sdk.start_session("main").run("next step", delivery="steer")
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "answer for next step"
|
||||
|
||||
|
||||
def test_concurrent_runs_keep_their_own_results(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
entered = threading.Event()
|
||||
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||
first = workers.submit(session.run, "hold-prompt", on_notification=lambda notice: entered.set())
|
||||
try:
|
||||
assert entered.wait(5), "the first run must observe its receipt"
|
||||
second = workers.submit(session.run, "/compact")
|
||||
assert isinstance(second.result(timeout=5), CommandExecution)
|
||||
finally:
|
||||
release(sdk)
|
||||
result = first.result(timeout=5)
|
||||
assert isinstance(result, RunResult)
|
||||
assert result.final_response == "answer for hold-prompt"
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
def test_command_result_needs_no_notifications(sdk: DeepSeekHarness) -> None:
|
||||
notices = []
|
||||
result = sdk.start_session("main").run("silent-command", on_notification=notices.append)
|
||||
assert isinstance(result, CommandExecution)
|
||||
assert notices == []
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError)])
|
||||
def test_run_reports_routing_failures_without_retry(sdk: DeepSeekHarness, text: str, error: type[Exception]) -> None:
|
||||
session = sdk.start_session("main")
|
||||
with pytest.raises(error):
|
||||
session.run(text)
|
||||
assert_released(sdk)
|
||||
assert isinstance(session.run("/compact"), CommandExecution)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["ordinary", "/compact"])
|
||||
def test_observer_error_releases_run_subscription(sdk: DeepSeekHarness, text: str) -> None:
|
||||
def reject(_notice: Notification) -> None:
|
||||
raise RuntimeError("observer failed")
|
||||
with pytest.raises(RuntimeError, match="observer failed"):
|
||||
sdk.start_session("main").run(text, on_notification=reject)
|
||||
assert_released(sdk)
|
||||
assert isinstance(sdk.start_session("main").run("after observer"), RunResult)
|
||||
|
||||
|
||||
def test_command_timeout_keeps_runtime_responsive(sdk: DeepSeekHarness) -> None:
|
||||
sdk.client.config.request_timeout_seconds = 0.05
|
||||
with pytest.raises(TimeoutError, match="session/query timed out"):
|
||||
sdk.start_session("main").run("hold-command")
|
||||
sdk.client.config.request_timeout_seconds = 5
|
||||
assert_released(sdk)
|
||||
release(sdk)
|
||||
assert isinstance(sdk.start_session("main").run("/compact"), CommandExecution)
|
||||
|
||||
|
||||
def test_run_requires_supported_delivery(sdk: DeepSeekHarness) -> None:
|
||||
session = sdk.start_session("main")
|
||||
with pytest.raises(ValueError, match="delivery"):
|
||||
session.run("ordinary", delivery="unknown")
|
||||
with pytest.raises(ValueError, match="content-block"):
|
||||
session.run([{"type": "text", "text": "ordinary"}], delivery="steer")
|
||||
Reference in new issue
Block a user