mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
feat(sdk): support native goal slash commands
This commit is contained in:
@@ -12,8 +12,8 @@
|
||||
en: c4c30913fe2642ef
|
||||
zh: 0ed42bca37fba60b
|
||||
/deepseek-harness-python-sdk/query-and-response:
|
||||
en: 1348c26311dee604
|
||||
zh: 9290f1d66da1781c
|
||||
en: c9e73f3b9934032f
|
||||
zh: bf44fb5d471dbe75
|
||||
/deepseek-harness-python-sdk/client-approvals:
|
||||
en: fc69be1beb8abff1
|
||||
zh: 91b56781931a6f68
|
||||
@@ -24,5 +24,5 @@
|
||||
en: 979ad00fe498b2be
|
||||
zh: 4b656bd268292a13
|
||||
/deepseek-harness-python-sdk/results-and-notifications:
|
||||
en: d4f7366c2d260639
|
||||
zh: 7de99aed2b1bdd0a
|
||||
en: e3318c54ef33e859
|
||||
zh: 048d2db7bcf5399f
|
||||
|
||||
@@ -61,18 +61,20 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
<a id="query-and-response"></a>
|
||||
## Query and response
|
||||
|
||||
Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The full SDK intercepts bare `/compact`, `/permission <preset>` with a non-blank preset, and `/plan [off|message]`, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/model` and other input remain ordinary prompts. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command.
|
||||
Call `session.query(text)` to send text, then iterate `session.receive_response()` to observe its result. `query()` returns `None` after sending, without waiting for runtime routing or execution. The full SDK intercepts bare `/compact`, `/permission <preset>` with a non-blank preset, `/plan [off|message]`, and `/goal [objective|clear|edit objective|pause|resume]`, including their registered Desktop aliases. Bare `/permission` or `/权限`, `/model` and other input remain ordinary prompts. This API accepts text strings only. `delivery="queue"` is the default; `delivery="steer"` directs an ordinary prompt to the next step. Command timing remains owned by the command.
|
||||
|
||||
`receive_response()` claims the next query in send order. It first waits for the routing response, then yields `Notification` objects followed by one terminal value: `RunResult` for the prompt's inbox-receipt-to-idle interval, or `CommandExecution` after the command settles. Notifications can accumulate before receiving begins. Separate receivers can observe their queries concurrently, and another `query()` can be sent while a receiver waits. Close an unfinished iterator to release only its local observation; this does not cancel or roll back runtime work. Resources are released before yielding the terminal value.
|
||||
|
||||
`CommandExecution` contains `command_id` and a `result` discriminated by `kind`. Success may include `text` and `source_event_seq`; an error includes `text`. Handler errors return `kind="error"`; routing, protocol and transport failures raise during receiving. Sending failures raise from `query()`. `request_timeout_seconds` bounds waiting for the routing response, not later prompt activity; an already-arrived response remains receivable after that deadline.
|
||||
|
||||
The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` strictly accepts `/compact`, `/permission <preset>`, or `/plan [off|message]` and rejects bare `/permission`. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
|
||||
The full `sdk` profile provides these commands; the default `sdk-minimal` profile does not provide command execution. `/compact` can make an auxiliary model request and completes without requiring a normal turn or idle notification; empty history can succeed without a summary. `run()` retains ordinary prompt semantics for slash text. The lower-level blocking `HarnessClient.session_command(session_id, line)` strictly accepts `/compact`, `/permission <preset>`, `/plan [off|message]`, or `/goal [objective|clear|edit objective|pause|resume]` and rejects bare `/permission`. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
|
||||
|
||||
`/permission <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).
|
||||
|
||||
`/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.
|
||||
|
||||
`/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).
|
||||
|
||||
<a id="client-approvals"></a>
|
||||
## Client approvals
|
||||
|
||||
@@ -133,6 +135,8 @@ Callbacks run on separate threads so the reader can process cancellation and oth
|
||||
|
||||
`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`.
|
||||
|
||||
`NotificationSubscription.next(timeout_seconds=...)` bounds one local notification wait and raises `TimeoutError` on expiry. Omitting the argument waits indefinitely. A timeout neither cancels runtime work nor closes the subscription; a later call can receive subsequent notifications.
|
||||
|
||||
`HarnessClient` retains discovered subagent ancestry for the runtime process lifetime. During `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and known descendants in wire order. `RunResult.events` contains root-session events only, so descendant output cannot replace the root response. The low-level `session_prompt()` returns the queued message id immediately; callers that bypass `Session.run()` own the later activity boundary.
|
||||
|
||||
The selected home stores profiles, plugins, and every profile-owned durable resource. The full `sdk` profile uses its credentials, settings, and session stores; `sdk-minimal` uses JSONL session storage and stores images under `$DSH_HOME/attachments/v1`, with request-image cache under `$DSH_HOME/cache/attachments`. Use a fresh home when those resources must be isolated, and a fresh session id for independent work. Reusing both a harness and session id continues the durable conversation and session-owned resources.
|
||||
|
||||
@@ -61,18 +61,20 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
|
||||
<a id="query-and-response"></a>
|
||||
## 查询与接收
|
||||
|
||||
调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。完整 SDK 接管裸 `/compact`、带非空 preset 的 `/permission <preset>` 和 `/plan [off|message]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 及其余输入仍是普通提示词。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。
|
||||
调用 `session.query(text)` 发送文本,再迭代 `session.receive_response()` 观察结果。`query()` 发送后即返回 `None`,不等待运行时路由或执行。完整 SDK 接管裸 `/compact`、带非空 preset 的 `/permission <preset>`、`/plan [off|message]` 和 `/goal [objective|clear|edit objective|pause|resume]`,包括绑定到其定义的 Desktop 别名。裸 `/permission` 或 `/权限`、`/model` 及其余输入仍是普通提示词。此 API 只接受文本字符串。`delivery="queue"` 是默认值;`delivery="steer"` 将普通提示词交给下一个 step。命令执行时机仍由命令决定。
|
||||
|
||||
`receive_response()` 按发送顺序领取下一次 query。它先等待路由响应,再逐个产出 `Notification`,最后产出一个终态值:普通提示词从收件箱回执到 idle 区间的 `RunResult`,或命令处理结束后的 `CommandExecution`。通知可以在开始接收前积累。不同接收器可以并行观察各自的 query,也可以在一个接收器等待期间发送另一个 `query()`。关闭未完成的迭代器只释放它自己的本地观察资源,不取消或回滚运行时操作。终态值产出前会释放资源。
|
||||
|
||||
`CommandExecution` 包含 `command_id` 和以 `kind` 区分的 `result`。成功结果可以包含 `text` 和 `source_event_seq`;错误结果包含 `text`。处理器错误返回 `kind="error"`;路由、协议和传输失败在接收时抛出。发送失败从 `query()` 抛出。`request_timeout_seconds` 限制路由响应的等待时间,不限制后续提示词活动;已经到达的响应在期限过后仍可接收。
|
||||
|
||||
完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 严格接受 `/compact`、`/permission <preset>` 或 `/plan [off|message]`,并拒绝裸 `/permission`。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
完整 `sdk` profile 提供这些命令;默认 `sdk-minimal` profile 不提供命令执行能力。`/compact` 可能发起辅助模型请求,完成时不要求普通轮次或 idle 通知;空历史可以不生成摘要而成功返回。`run()` 对 slash 文本保留普通提示词语义。底层阻塞式 `HarnessClient.session_command(session_id, line)` 严格接受 `/compact`、`/permission <preset>`、`/plan [off|message]` 或 `/goal [objective|clear|edit objective|pause|resume]`,并拒绝裸 `/permission`。另见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
|
||||
|
||||
`/permission <preset>` 委托给原生权限 handler。未知 preset 返回 `CommandExecution.result.kind="error"`,不改变权限状态。成功的变更属于该 Session:后续 query 和恢复保留该状态,其他 Session 保留各自状态,新 Session 使用配置的默认值。`DeepSeekHarnessConfig` 仍是启动输入,不是共享的实时状态镜像;命令不重写它或已保存的用户默认值。运行中改变审批策略可能注入通知并触发额外模型 step。另见[权限示例](examples/README.zh.md#change-session-permissions)。
|
||||
|
||||
`/plan` 进入计划模式,`/plan off` 退出,`/plan <message>` 进入后通过原生 steer 提交去除首尾空白的正文。返回的 `CommandExecution` 只表示命令完成,不表示规划或审查结束。需要普通活动结果时,可以先用 `/plan` 进入,再单独提交普通 query。组合输入则应在发送前订阅,并在保持问答处理器运行的同时使用 `session/wait`,见[计划示例](examples/README.zh.md#review-a-plan)。计划模式改变指导并渐进披露 `exit_plan_mode`,不修改权限设置。默认指导禁止编辑文件,包括计划文档;实际执行仍由沙箱控制。
|
||||
|
||||
`/goal` 显示当前目标;`/goal <objective>`、`edit <objective>`、`pause`、`resume` 和 `clear` 使用原生目标 handler。创建和恢复可在命令返回后启动自动轮次。发送前应订阅通知,观察终态 `goal/change` 操作,再调用 `session/wait` 等待可能的模型收尾回复。轮次之间的 idle 不代表目标完成。恢复的活动目标保持 disarmed,直到显式恢复。目标命令保留权限设置和启动配置。另见[目标示例](examples/README.zh.md#run-a-goal)。
|
||||
|
||||
<a id="client-approvals"></a>
|
||||
## 客户端审批
|
||||
|
||||
@@ -133,6 +135,8 @@ with DeepSeekHarness(
|
||||
|
||||
`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。
|
||||
|
||||
`NotificationSubscription.next(timeout_seconds=...)` 限制单次本地通知等待,超时抛出 `TimeoutError`;省略参数则无限等待。超时不取消运行时工作,也不关闭订阅,后续调用仍可接收通知。
|
||||
|
||||
`HarnessClient` 会在运行时进程的整个生命周期内保留已发现的 subagent 谱系。在 `Session.run()` 期间,`RunResult.notifications` 与 `on_notification` 按协议顺序接收根会话和已知后代的通知。`RunResult.events` 只包含根会话事件,因此后代输出不会替换根响应。底层 `session_prompt()` 会立即返回已排队消息的 id;绕过 `Session.run()` 的调用方自行负责后续活动边界。
|
||||
|
||||
所选 home 保存 profile、插件与每个 profile 自有的持久资源。完整 `sdk` profile 使用其中的凭据、设置与会话存储;`sdk-minimal` 保存 JSONL 会话,将图片存储在 `$DSH_HOME/attachments/v1` 下,请求图片缓存位于 `$DSH_HOME/cache/attachments` 下。需要隔离这些资源时应使用新的 home;独立工作应使用新的会话 ID。同时复用 harness 与会话 ID 会延续持久对话和会话资源。
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
en: 3471642d8444d0e9
|
||||
zh: 7831f28295a6e68a
|
||||
/python-sdk-example/review-a-plan:
|
||||
en: b013d5ab5e24bb83
|
||||
zh: 84ed4333970b9dc4
|
||||
en: bd0da5138a033eba
|
||||
zh: f291c3ae8967e936
|
||||
/python-sdk-example/run-a-goal:
|
||||
en: ae73e9fd9d402047
|
||||
zh: f9694bc6a6e12929
|
||||
/python-sdk-example/add-plugins:
|
||||
en: 5e1e30dac13f6c9e
|
||||
zh: 0af98ebb75c88f43
|
||||
|
||||
@@ -70,6 +70,20 @@ python python/sdk/examples/plan.py \
|
||||
|
||||
The native plan is carried in `exit_plan_mode`, not written to a workspace document. Permissions remain workspace-write. Dismissal leaves plan mode active and ends the current turn. The example waits through `session/wait`; its question callback is independent of the protocol reader.
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## Run a goal
|
||||
|
||||
The [goal example](goal.py) submits `/goal <objective>`, prints goal changes and assistant replies, and waits for the final reply after a terminal goal event. It uses the full SDK profile and workspace-write permissions.
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/goal.py \
|
||||
--dsh-home /absolute/path/to/goal-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"Calculate 17 * 19, verify the result, and complete the goal without changing files."
|
||||
```
|
||||
|
||||
`--timeout-seconds` bounds observation (300 seconds by default). A timeout reports failure and closes the runtime; it does not mark the goal complete. The persisted goal can be inspected after restoration and explicitly resumed. `session/wait` alone is insufficient because automatic rounds have idle gaps.
|
||||
|
||||
## Add plugins
|
||||
|
||||
Use the runtime wheel's `dsh` command against the same explicit home for persistent profile changes:
|
||||
|
||||
@@ -70,6 +70,20 @@ python python/sdk/examples/plan.py \
|
||||
|
||||
原生计划通过 `exit_plan_mode` 携带,而不是写入工作区文档。权限仍为 workspace-write。关闭审查会保留计划模式并结束当前轮次。示例通过 `session/wait` 等待,问答回调独立于协议读取线程。
|
||||
|
||||
<a id="run-a-goal"></a>
|
||||
## 运行目标
|
||||
|
||||
[目标示例](goal.py) 提交 `/goal <objective>`,打印目标变化和助手回复,并在终态目标事件后等待最后的回复。示例使用完整 SDK profile 和 workspace-write 权限。
|
||||
|
||||
```sh
|
||||
python python/sdk/examples/goal.py \
|
||||
--dsh-home /absolute/path/to/goal-dsh-home \
|
||||
--workspace /absolute/path/to/disposable-workspace \
|
||||
"Calculate 17 * 19, verify the result, and complete the goal without changing files."
|
||||
```
|
||||
|
||||
`--timeout-seconds` 限制观察时间,默认为 300 秒。超时会报告失败并关闭运行时,不会将目标标记为完成。恢复会话后可查看持久化目标,再显式恢复执行。自动轮次之间存在 idle 间隙,因此仅调用 `session/wait` 不足以等待目标结束。
|
||||
|
||||
## 添加插件
|
||||
|
||||
对同一个显式 home 使用运行时 wheel 包提供的 `dsh` 命令,以进行持久 profile 变更:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set a goal and observe automatic rounds through the final model reply."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from deepseek_harness import CommandExecution, DeepSeekHarness, Notification
|
||||
from deepseek_harness.api import final_response
|
||||
|
||||
|
||||
class WaitResult(BaseModel):
|
||||
"""The empty session/wait result."""
|
||||
|
||||
|
||||
def display(notification: Notification) -> dict | None:
|
||||
"""Print durable assistant output and return the observed root Session event."""
|
||||
if notification.method != "session.event":
|
||||
return None
|
||||
event = notification.payload["event"]
|
||||
if event["type"] == "assistant/message":
|
||||
text = final_response([event])
|
||||
if text:
|
||||
print(text, flush=True)
|
||||
elif event["type"] == "goal/change":
|
||||
print(f"Goal: {event['data']['operation']}", flush=True)
|
||||
elif event["type"] == "turn/end" and event["data"]["reason"]["kind"] != "completed":
|
||||
raise RuntimeError(f"Goal activity ended: {event['data']['reason']}")
|
||||
return event
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Observe goal state before waiting for the final activity interval."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsh-home", required=True, type=Path)
|
||||
parser.add_argument("--workspace", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash"))
|
||||
parser.add_argument("--timeout-seconds", type=float, default=300)
|
||||
parser.add_argument("objective", help="Objective for the native /goal command")
|
||||
args = parser.parse_args()
|
||||
if not args.objective.strip() or args.timeout_seconds <= 0:
|
||||
parser.error("objective must be non-blank and timeout-seconds must be positive")
|
||||
with DeepSeekHarness(
|
||||
dsh_home=str(args.dsh_home.resolve()), cwd=str(args.workspace.resolve()),
|
||||
profile="sdk", model=args.model, env={"DSH_PERMISSION_MODE": "workspace-write"},
|
||||
) as harness:
|
||||
session = harness.start_session()
|
||||
with harness.client.subscribe_notifications(lambda notice: notice.payload.get("sessionId") == session.id) as notifications:
|
||||
deadline = time.monotonic() + args.timeout_seconds
|
||||
session.query(f"/goal {args.objective}")
|
||||
for message in session.receive_response():
|
||||
if isinstance(message, CommandExecution):
|
||||
print(message.result.text, flush=True)
|
||||
if message.result.kind == "error":
|
||||
raise RuntimeError(message.result.text)
|
||||
while True:
|
||||
event = display(notifications.next(timeout_seconds=max(0, deadline - time.monotonic())))
|
||||
if event is not None and event["type"] == "goal/change" and event["data"]["operation"] in {
|
||||
"complete", "block", "pause", "clear",
|
||||
}:
|
||||
break
|
||||
# A terminal goal event can precede the model's closing reply.
|
||||
harness.client.request(
|
||||
"session/wait", {"sessionId": session.id}, response_model=WaitResult,
|
||||
timeout_seconds=max(0, deadline - time.monotonic()),
|
||||
on_notification=display, notification_subscription=notifications,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -720,8 +720,12 @@ class NotificationSubscription:
|
||||
self.close()
|
||||
self._notifications.put(error)
|
||||
|
||||
def next(self) -> Notification:
|
||||
item = self._notifications.get()
|
||||
def next(self, timeout_seconds: float | None = None) -> Notification:
|
||||
"""Read one notification; an optional timeout stops only this local wait."""
|
||||
try:
|
||||
item = self._notifications.get(timeout=timeout_seconds)
|
||||
except queue.Empty as exc:
|
||||
raise TimeoutError("timed out waiting for an SDK notification") from exc
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
@@ -596,6 +596,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe
|
||||
assert notification.payload["sessionId"] == "other"
|
||||
|
||||
|
||||
def test_notification_timeout_keeps_subscription_available_for_later_events() -> None:
|
||||
client = HarnessClient()
|
||||
with client.subscribe_notifications() as subscription:
|
||||
with pytest.raises(TimeoutError, match="timed out waiting for an SDK notification"):
|
||||
subscription.next(timeout_seconds=0)
|
||||
client._handle_message({"jsonrpc": "2.0", "method": "tick", "params": {"value": 1}})
|
||||
assert subscription.next(timeout_seconds=0).payload == {"value": 1}
|
||||
|
||||
|
||||
def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None:
|
||||
client = HarnessClient()
|
||||
with client.subscribe_session_notifications("main") as first:
|
||||
|
||||
@@ -103,7 +103,7 @@ def test_low_level_command_preserves_the_line(peer: Path, tmp_path: Path) -> Non
|
||||
def test_command_rpc_rejection_does_not_fall_back_to_prompt(peer: Path, tmp_path: Path) -> None:
|
||||
with harness(peer, tmp_path, {"error": {"code": -32602, "message": "unsupported command"}}) as sdk:
|
||||
with pytest.raises(JsonRpcError, match="unsupported command") as raised:
|
||||
sdk.client.session_command("main", "/goal")
|
||||
sdk.client.session_command("main", "/model")
|
||||
assert raised.value.code == -32602
|
||||
assert [frame["method"] for frame in frames(tmp_path)] == ["initialize", "session/command", "shutdown"]
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ _T = TypeVar("_T")
|
||||
class _Completion:
|
||||
text: str
|
||||
blocked: bool = False
|
||||
tool_call: tuple[str, dict] | None = None
|
||||
tool_call: tuple[str, dict] | Callable[[dict], tuple[str, dict]] | None = None
|
||||
allow_disconnect: bool = False
|
||||
entered: threading.Event = field(default_factory=threading.Event)
|
||||
release: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
@@ -98,13 +99,18 @@ class _ModelServer:
|
||||
completion.entered.set()
|
||||
# The test owns release. This larger bound only contains a failed test.
|
||||
assert completion.release.wait(_WAIT_SECONDS * 2), "model barrier was not released"
|
||||
payload = _messages_response(completion.text, body["model"], ordinal, completion.tool_call)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
self.wfile.flush()
|
||||
tool_call = completion.tool_call(body) if callable(completion.tool_call) else completion.tool_call
|
||||
payload = _messages_response(completion.text, body["model"], ordinal, tool_call)
|
||||
try:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
if not completion.allow_disconnect:
|
||||
raise
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
pass
|
||||
@@ -182,6 +188,7 @@ def _runtime(
|
||||
tmp_path: Path, completions: list[_Completion], *, capture_patch: Path | None = None,
|
||||
initial_permission: str = "danger-full-access",
|
||||
on_user_question: UserQuestionHandler | None = None,
|
||||
max_goal_rounds: int | None = None,
|
||||
) -> Iterator[_Runtime]:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir(exist_ok=True)
|
||||
@@ -194,6 +201,9 @@ def _runtime(
|
||||
+ "\n compression: none\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if max_goal_rounds is not None:
|
||||
with patch.open("a", encoding="utf-8") as extra:
|
||||
extra.write(f"- id: goal\n config:\n defaultMaxGoalRounds: {max_goal_rounds}\n")
|
||||
patches = (patch,) if capture_patch is None else (patch, capture_patch.resolve())
|
||||
dsh_bin = _dsh_bin()
|
||||
with _ModelServer(completions) as model, ThreadPoolExecutor(max_workers=2) as workers:
|
||||
@@ -456,10 +466,10 @@ def test_unknown_slash_and_compact_arguments_follow_the_plain_prompt_path(tmp_pa
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2
|
||||
|
||||
|
||||
def test_unimplemented_slash_remains_plain_text_without_creating_a_goal(tmp_path: Path) -> None:
|
||||
input_text = "/goal DSH_GOAL_TEXT_INPUT"
|
||||
def test_unimplemented_slash_remains_plain_text_without_recording_feedback(tmp_path: Path) -> None:
|
||||
input_text = "/feedback DSH_FEEDBACK_TEXT_INPUT"
|
||||
following = "DSH_AFTER_PLAN_TEXT_INPUT"
|
||||
reply_before = "DSH_GOAL_TEXT_REPLY"
|
||||
reply_before = "DSH_FEEDBACK_TEXT_REPLY"
|
||||
reply = "DSH_AFTER_PLAN_TEXT_REPLY"
|
||||
with _runtime(tmp_path, [_Completion(reply_before), _Completion(reply)]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
@@ -478,7 +488,7 @@ def test_unimplemented_slash_remains_plain_text_without_creating_a_goal(tmp_path
|
||||
assert initial["tools"] == continued["tools"]
|
||||
events = _read_events(runtime.logs)
|
||||
assert not _of_type(events, "command/run")
|
||||
assert not any(event["type"].startswith("goal/") for event in events)
|
||||
assert not any(event["type"].startswith("feedback/") for event in events)
|
||||
assert len(_of_type(events, "turn/start")) == len(_of_type(events, "turn/end")) == 2
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Native goal rounds and state changes through the Python SDK profile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from deepseek_harness import CommandExecution, RunResult, Session
|
||||
from deepseek_harness.client import NotificationSubscription
|
||||
from test_commands_runtime import (
|
||||
_Completion, _Runtime, _SESSION_ID, _WAIT_SECONDS, _assert_request_permission,
|
||||
_command_pair, _of_type, _permission_facts, _read_events, _receive_result,
|
||||
_request_texts, _runtime, _text_blocks,
|
||||
)
|
||||
from test_plan_runtime import _WaitResult, _wait_activity
|
||||
|
||||
|
||||
def _command(runtime: _Runtime, session: Session, text: str, *, kind: str = "success") -> CommandExecution:
|
||||
runtime.call(session.query, text)
|
||||
result = runtime.call(_receive_result, session)
|
||||
assert isinstance(result, CommandExecution)
|
||||
assert result.result.kind == kind, result.result.text
|
||||
return result
|
||||
|
||||
|
||||
def _until(subscription: NotificationSubscription, event_type: str, operation: str | None = None) -> dict:
|
||||
deadline = time.monotonic() + _WAIT_SECONDS
|
||||
while True:
|
||||
notification = subscription.next(timeout_seconds=max(0, deadline - time.monotonic()))
|
||||
if notification.method != "session.event":
|
||||
continue
|
||||
event = notification.payload["event"]
|
||||
if event["type"] == event_type and (operation is None or event["data"].get("operation") == operation):
|
||||
return event["data"]
|
||||
|
||||
|
||||
def _complete_goal(request: dict) -> tuple[str, dict]:
|
||||
results = [block for message in request["messages"] for block in message["content"]
|
||||
if block["type"] == "tool_result"]
|
||||
current = json.loads("\n".join(_text_blocks(results[-1]["content"])))
|
||||
goal = current["goal"]
|
||||
assert goal["phase"] == "active" and current["activation"] == "armed"
|
||||
return "update_goal", {"goal_id": goal["id"], "revision": goal["revision"], "action": "complete"}
|
||||
|
||||
|
||||
def _completion_steps(final: _Completion | None = None) -> list[_Completion]:
|
||||
return [_Completion("", tool_call=("get_goal", {})), _Completion("", tool_call=_complete_goal),
|
||||
final if final is not None else _Completion("GOAL_DONE")]
|
||||
|
||||
|
||||
def test_goal_reads_and_invalid_controls_do_not_start_model_work(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, []) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
executions = [_command(runtime, session, "/目标")]
|
||||
for line in ["/goal edit", "/goal edit Missing goal", "/goal pause", "/goal resume"]:
|
||||
executions.append(_command(runtime, session, line, kind="error"))
|
||||
executions.append(_command(runtime, session, "/goal clear"))
|
||||
assert runtime.model.captured() == []
|
||||
events = _read_events(runtime.logs)
|
||||
assert not _of_type(events, "goal/change") and not _of_type(events, "turn/start")
|
||||
for execution in executions:
|
||||
_command_pair(events, execution, "goal")
|
||||
|
||||
|
||||
def test_goal_continues_rounds_and_finishes_wrapup_after_completion_event(tmp_path: Path) -> None:
|
||||
final = _Completion("GOAL_DONE", blocked=True)
|
||||
with _runtime(tmp_path, [_Completion("PROGRESS"), *_completion_steps(final)],
|
||||
initial_permission="workspace-write", max_goal_rounds=3) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
execution = _command(runtime, session, "/目标 Verify the small change.")
|
||||
completed = _until(events, "goal/change", "complete")
|
||||
assert completed["goal"]["phase"] == "complete" and completed["roundsStarted"] == 2
|
||||
assert final.entered.wait(_WAIT_SECONDS)
|
||||
waiting = runtime.workers.submit(runtime.harness.client.request, "session/wait",
|
||||
{"sessionId": session.id}, response_model=_WaitResult)
|
||||
assert not waiting.done() and not final.release.is_set()
|
||||
final.release.set()
|
||||
waiting.result(timeout=_WAIT_SECONDS)
|
||||
assert _until(events, "turn/end")["turn"] == 2
|
||||
requests = runtime.model.captured()
|
||||
assert len(requests) == 4
|
||||
assert 'Objective: "Verify the small change."\nRound: 1/3' in "\n".join(_request_texts(requests[0]))
|
||||
assert 'Round: 2/3' in "\n".join(_request_texts(requests[1]))
|
||||
assert '<goal_complete>' in "\n".join(_request_texts(requests[-1]))
|
||||
for request in requests:
|
||||
_assert_request_permission(request, "workspace-write", "ask")
|
||||
assert runtime.harness.config.env["DSH_PERMISSION_MODE"] == "workspace-write"
|
||||
events = _read_events(runtime.logs)
|
||||
_command_pair(events, execution, "goal")
|
||||
assert [e["data"]["turn"] for e in _of_type(events, "turn/start")] == [1, 2]
|
||||
assert [e["data"] for e in _of_type(events, "step/start")] == [
|
||||
{"turn": 1, "step": 1}, {"turn": 2, "step": 1}, {"turn": 2, "step": 2}, {"turn": 2, "step": 3},
|
||||
]
|
||||
assert len(_permission_facts(events)) == 3
|
||||
|
||||
|
||||
def test_pause_aborts_then_edit_and_resume_use_revised_goal_without_changing_other_sessions(tmp_path: Path) -> None:
|
||||
first = _Completion("CANCELLED_RESPONSE", blocked=True, allow_disconnect=True)
|
||||
with _runtime(tmp_path, [first, *_completion_steps(), _Completion("RAW_GOAL_TEXT")]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal Original objective")
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/goal Duplicate objective", kind="error")
|
||||
_command(runtime, session, "/goal edit", kind="error")
|
||||
_command(runtime, session, "/goal resume", kind="error")
|
||||
_command(runtime, session, "/goal PAUSE")
|
||||
assert _until(events, "turn/end")["reason"]["kind"] == "aborted"
|
||||
assert not first.release.is_set()
|
||||
_command(runtime, session, "/goal edit Revised objective")
|
||||
assert len(runtime.model.captured()) == 1
|
||||
other = runtime.harness.start_session("goal-other")
|
||||
assert "No goal is currently set" in _command(runtime, other, "/goal").result.text
|
||||
_command(runtime, session, "/goal resume")
|
||||
_until(events, "goal/change", "complete")
|
||||
_wait_activity(runtime, session)
|
||||
assert 'Objective: "Revised objective"' in "\n".join(_request_texts(runtime.model.captured()[1]))
|
||||
_command(runtime, session, "/goal clear")
|
||||
_command(runtime, session, "/goal clear")
|
||||
result = runtime.call(session.run, "/goal Plain text objective")
|
||||
assert isinstance(result, RunResult) and result.final_response == "RAW_GOAL_TEXT"
|
||||
assert "/goal Plain text objective" in _request_texts(runtime.model.captured()[-1])
|
||||
changes = _of_type(_read_events(runtime.logs), "goal/change")
|
||||
assert [e["data"]["operation"] for e in changes] == ["create", "pause", "edit", "resume", "complete", "clear"]
|
||||
assert not _of_type(_read_events(runtime.logs, session_id="goal-other"), "goal/change")
|
||||
|
||||
|
||||
def test_clear_keeps_current_request_and_stops_further_goal_rounds(tmp_path: Path) -> None:
|
||||
first = _Completion("CURRENT_ROUND_FINISHED", blocked=True)
|
||||
with _runtime(tmp_path, [first]) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal Work until cleared")
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/goal clear")
|
||||
first.release.set()
|
||||
_wait_activity(runtime, session)
|
||||
assert _until(events, "turn/end")["reason"]["kind"] == "completed"
|
||||
assert len(runtime.model.captured()) == 1
|
||||
events = _read_events(runtime.logs)
|
||||
assert len(_of_type(events, "assistant/message")) == 1
|
||||
assert [e["data"]["operation"] for e in _of_type(events, "goal/change")] == ["create", "clear"]
|
||||
|
||||
|
||||
def test_round_limit_blocks_and_rejects_resume_without_another_request(tmp_path: Path) -> None:
|
||||
with _runtime(tmp_path, [_Completion("ONE"), _Completion("TWO")], max_goal_rounds=2) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal An unfinished objective")
|
||||
blocked = _until(events, "goal/change", "block")
|
||||
assert blocked["goal"]["blockedReason"]["code"] == "round-limit"
|
||||
assert blocked["roundsStarted"] == 2
|
||||
_command(runtime, session, "/goal resume", kind="error")
|
||||
_wait_activity(runtime, session)
|
||||
assert len(runtime.model.captured()) == 2
|
||||
|
||||
|
||||
def test_restored_active_goal_waits_for_explicit_resume(tmp_path: Path) -> None:
|
||||
first = _Completion("INTERRUPTED", blocked=True, allow_disconnect=True)
|
||||
with _runtime(tmp_path, [first], max_goal_rounds=3) as runtime:
|
||||
session = runtime.harness.start_session(_SESSION_ID)
|
||||
runtime.call(runtime.harness.client.session_prompt, session.id, [{"type": "text", "text": "Start an ordinary task"}])
|
||||
assert first.entered.wait(_WAIT_SECONDS)
|
||||
_command(runtime, session, "/goal Persisted objective")
|
||||
runtime.harness.close()
|
||||
with _runtime(tmp_path, _completion_steps()) as runtime:
|
||||
session = runtime.call(runtime.harness.resume_session, _SESSION_ID)
|
||||
shown = _command(runtime, session, "/goal")
|
||||
assert "Status: active" in shown.result.text and "Activation: disarmed" in shown.result.text
|
||||
assert runtime.model.captured() == []
|
||||
with runtime.harness.client.subscribe_session_notifications(session.id) as events:
|
||||
_command(runtime, session, "/goal resume")
|
||||
_until(events, "goal/change", "complete")
|
||||
_wait_activity(runtime, session)
|
||||
assert 'Objective: "Persisted objective"\nRound: 1/3' in "\n".join(_request_texts(runtime.model.captured()[0]))
|
||||
@@ -141,7 +141,7 @@ def test_query_only_sends_then_receive_returns_command(sdk: DeepSeekHarness) ->
|
||||
assert_released(sdk)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/goal", "/permission"])
|
||||
@pytest.mark.parametrize("text", ["ordinary", "fast-prompt", "/model", "/permission"])
|
||||
@pytest.mark.parametrize("delivery", ["queue", "steer"])
|
||||
def test_prompt_yields_receipt_through_idle_then_run_result(sdk: DeepSeekHarness, text: str, delivery: str) -> None:
|
||||
session = sdk.start_session("main")
|
||||
|
||||
Reference in New Issue
Block a user