Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

This commit is contained in:
creatixchu
2026-07-24 20:01:36 +08:00
107 changed files with 4589 additions and 502 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b
README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534
README.md: bfa31a712acd6fccf1458a0a80fc2ff80dfe114e
README.zh.md: 11ebcdd133b2fd839b73f50ef2be2e531e8bbc2a
+1 -3
View File
@@ -34,9 +34,7 @@ with DeepSeekHarness(
`provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. The bundled default composition registers `deepseek`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog.
`TurnResult.final_response` is the text content from the last
`assistant/message` event in the turn. Use `TurnResult.events` for the complete
event stream, including intermediate assistant messages and tool activity.
`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `TurnResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `TurnResult.events` remains the root session's complete event stream, and `TurnResult.final_response` is the text content from its last `assistant/message`; descendant messages therefore cannot replace the root response.
The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them.
+1 -1
View File
@@ -30,7 +30,7 @@ with DeepSeekHarness(
`provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。内置默认组合注册 `deepseek`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。
`TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取
`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications` `on_notification` 会按线上的原始顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复
同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin``bridge_bin``launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。
+4 -1
View File
@@ -143,7 +143,10 @@ class Session:
notifications.append(notification)
if on_notification is not None:
on_notification(notification)
if notification.method == "session.event":
if (
notification.method == "session.event"
and notification.payload.get("sessionId") == self.id
):
event = notification.payload.get("event")
if isinstance(event, dict):
events.append(event)
+53 -14
View File
@@ -47,6 +47,7 @@ class HarnessClient:
self._notification_subscribers: dict[
str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None]
] = {}
self._session_parents: dict[str, str] = {}
self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue()
self._stderr_lines: deque[str] = deque(maxlen=400)
self._reader_thread: threading.Thread | None = None
@@ -62,6 +63,8 @@ class HarnessClient:
def start(self) -> None:
if self._proc is not None:
return
with self._lock:
self._session_parents.clear()
args = list(self.config.launch_args_override or self._default_launch_args())
env = os.environ.copy()
if self.config.env:
@@ -143,7 +146,7 @@ class HarnessClient:
payload,
response_model=_SessionPromptResponse,
on_notification=on_notification,
notification_filter=_notification_belongs_to_session(session_id),
notification_filter=self._notification_belongs_to_session_tree(session_id),
notification_subscription=notification_subscription,
)
@@ -193,7 +196,8 @@ class HarnessClient:
return NotificationSubscription(self, subscription_id, notifications)
def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription":
return self.subscribe_notifications(_notification_belongs_to_session(session_id))
"""Subscribe to a session and descendants discovered from subagent lifecycle edges."""
return self.subscribe_notifications(self._notification_belongs_to_session_tree(session_id))
def next_request(self) -> IncomingRequest:
item = self._requests.get()
@@ -352,6 +356,7 @@ class HarnessClient:
params = message.get("params")
notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
with self._lock:
self._record_session_relationship_locked(notification)
subscribers = list(self._notification_subscribers.items())
delivered = False
for subscription_id, (subscriber, predicate) in subscribers:
@@ -439,6 +444,52 @@ class HarnessClient:
with self._lock:
self._notification_subscribers.pop(subscription_id, None)
def _record_session_relationship_locked(self, notification: Notification) -> None:
if notification.method != "subagent.started":
return
parent_id = notification.payload.get("parentSessionId")
child_id = notification.payload.get("childSessionId")
if (
isinstance(parent_id, str)
and parent_id
and isinstance(child_id, str)
and child_id
and parent_id != child_id
):
self._session_parents[child_id] = parent_id
def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter:
def belongs(notification: Notification) -> bool:
payload = notification.payload
if notification.method in {"subagent.started", "subagent.finished"}:
parent_id = payload.get("parentSessionId")
if (
isinstance(parent_id, str)
and self._session_is_descendant_of(parent_id, session_id)
):
return True
return payload.get("childSessionId") == session_id
related_id = payload.get("sessionId")
return (
isinstance(related_id, str)
and self._session_is_descendant_of(related_id, session_id)
)
return belongs
def _session_is_descendant_of(self, session_id: str, root_session_id: str) -> bool:
current = session_id
visited: set[str] = set()
while current not in visited:
if current == root_session_id:
return True
visited.add(current)
parent = self._session_parents.get(current)
if parent is None:
return False
current = parent
return False
class NotificationSubscription:
def __init__(
@@ -491,15 +542,3 @@ class _ShutdownResponse(BaseModel):
def _int_or_none(value: object) -> int | None:
return value if isinstance(value, int) else None
def _notification_belongs_to_session(session_id: str) -> NotificationFilter:
def belongs(notification: Notification) -> bool:
payload = notification.payload
return (
payload.get("sessionId") == session_id
or payload.get("parentSessionId") == session_id
or payload.get("childSessionId") == session_id
)
return belongs
+146 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
import pytest
from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig
from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification
def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None:
@@ -198,6 +198,65 @@ for line in sys.stdin:
]
def test_session_run_collects_nested_subagent_tree_without_polluting_root_events(
tmp_path: Path,
) -> None:
script = tmp_path / "fake_runtime.py"
script.write_text(
"""
import json
import sys
for line in sys.stdin:
msg = json.loads(line)
method = msg.get("method")
if method == "initialize":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
elif method == "session/prompt":
root = (msg.get("params") or {})["sessionId"]
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "grandchild", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "grandchild response"}]}}}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "child", "childSessionId": "grandchild", "status": "ok"}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": root, "childSessionId": "child", "status": "ok"}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "root response"}]}}}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": root, "status": "ok"}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True)
elif method == "shutdown":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
break
""".strip()
)
seen: list[str] = []
with DeepSeekHarness(
launch_args_override=(sys.executable, str(script)),
cwd=str(tmp_path),
) as harness:
result = harness.run(
"delegate recursively",
session_id="main",
on_notification=lambda notification: seen.append(notification.method),
)
assert harness.client._notifications.qsize() == 0
assert result.status == "ok"
assert result.final_response == "root response"
assert [event["data"]["content"][0]["text"] for event in result.events] == ["root response"]
assert [notification.method for notification in result.notifications] == [
"subagent.started",
"session.event",
"subagent.started",
"session.event",
"subagent.finished",
"subagent.finished",
"session.event",
"session.finished",
]
assert seen == [notification.method for notification in result.notifications]
def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None:
script = tmp_path / "fake_runtime.py"
script.write_text(
@@ -356,6 +415,92 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe
assert notification.payload["sessionId"] == "other"
def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None:
client = HarnessClient()
with client.subscribe_session_notifications("main") as first:
client._handle_message({
"jsonrpc": "2.0",
"method": "subagent.started",
"params": {"parentSessionId": "main", "childSessionId": "child"},
})
assert first.next().payload["childSessionId"] == "child"
with client.subscribe_session_notifications("main") as second:
client._handle_message({
"jsonrpc": "2.0",
"method": "subagent.started",
"params": {"parentSessionId": "child", "childSessionId": "grandchild"},
})
client._handle_message({
"jsonrpc": "2.0",
"method": "session.event",
"params": {"sessionId": "grandchild", "event": {"type": "assistant/message"}},
})
assert second.next().payload["childSessionId"] == "grandchild"
assert second.next().payload["sessionId"] == "grandchild"
assert client._notifications.qsize() == 0
def test_session_subscription_preserves_reused_child_ancestry_after_late_finish() -> None:
client = HarnessClient()
old_seen: list[Notification] = []
new_seen: list[Notification] = []
with (
client.subscribe_session_notifications("old-parent") as old_subscription,
client.subscribe_session_notifications("new-parent") as new_subscription,
):
client._handle_message({
"jsonrpc": "2.0",
"method": "subagent.started",
"params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"},
})
old_subscription.drain(old_seen.append)
new_subscription.drain(new_seen.append)
assert [notification.method for notification in old_seen] == ["subagent.started"]
assert new_seen == []
client._handle_message({
"jsonrpc": "2.0",
"method": "subagent.started",
"params": {"parentSessionId": "new-parent", "childSessionId": "reused-child"},
})
old_subscription.drain(old_seen.append)
new_subscription.drain(new_seen.append)
assert [notification.method for notification in new_seen] == ["subagent.started"]
client._handle_message({
"jsonrpc": "2.0",
"method": "subagent.finished",
"params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"},
})
old_subscription.drain(old_seen.append)
new_subscription.drain(new_seen.append)
assert [notification.method for notification in old_seen] == [
"subagent.started",
"subagent.finished",
]
assert [notification.method for notification in new_seen] == ["subagent.started"]
client._handle_message({
"jsonrpc": "2.0",
"method": "session.event",
"params": {"sessionId": "reused-child", "event": {"type": "assistant/message"}},
})
old_subscription.drain(old_seen.append)
new_subscription.drain(new_seen.append)
assert [notification.method for notification in old_seen] == [
"subagent.started",
"subagent.finished",
]
assert [notification.method for notification in new_seen] == [
"subagent.started",
"session.event",
]
assert client._notifications.qsize() == 0
def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None:
script = tmp_path / "fake_bridge.py"
script.write_text(