refactor: remove per-followup result attribution

This commit is contained in:
_Kerman
2026-07-30 16:48:28 +08:00
parent f6db60b52c
commit a6baddaaac
72 changed files with 586 additions and 1059 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 python/sdk/README.md
README.md: f1e16e724efd6f71f63e475e47d7e4d704b8ceac
README.zh.md: 11ff235a0eea23acab5f1737537c799021c35bea
README.md: 81bac18690665e4f48e6edb0a7053d7979e613df
README.zh.md: 8ce2fe85ec38327ddc9183661c3b26fe97dfa630
+3 -1
View File
@@ -35,7 +35,9 @@ with DeepSeekHarness(
`provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. 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.
`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.
`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, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle.
`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves.
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.
+3 -1
View File
@@ -31,7 +31,9 @@ with DeepSeekHarness(
`provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent 及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。
`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications``on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流,`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复
`Session.run()` 拥有一个从提示词的持久 inbox 回执开始、到整个 agent 下一次进入 idle 为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中
`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications``on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。
同样的行为也可以通过 `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)。
+2 -2
View File
@@ -1,4 +1,4 @@
from .api import DeepSeekHarness, DeepSeekHarnessConfig, Session, TurnResult
from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
from .client import HarnessClient, HarnessConfig
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
@@ -6,7 +6,7 @@ __all__ = [
"DeepSeekHarness",
"DeepSeekHarnessConfig",
"Session",
"TurnResult",
"RunResult",
"HarnessClient",
"HarnessConfig",
"IncomingRequest",
+30 -15
View File
@@ -35,9 +35,8 @@ class DeepSeekHarnessConfig:
@dataclass(slots=True)
class TurnResult:
class RunResult:
session_id: str
status: str
final_response: str
events: list[JsonObject]
notifications: list[Notification]
@@ -119,7 +118,7 @@ class DeepSeekHarness:
*,
session_id: str | None = None,
on_notification: Callable[[Notification], None] | None = None,
) -> TurnResult:
) -> RunResult:
return self.start_session(session_id).run(input, on_notification=on_notification)
@@ -133,15 +132,12 @@ class Session:
input: str | list[JsonObject],
*,
on_notification: Callable[[Notification], None] | None = None,
) -> TurnResult:
) -> RunResult:
content_blocks = normalize_input(input)
notifications: list[Notification] = []
events: list[JsonObject] = []
status = "error"
finished = False
def collect(notification: Notification) -> None:
nonlocal finished, status
notifications.append(notification)
if on_notification is not None:
on_notification(notification)
@@ -152,25 +148,31 @@ class Session:
event = notification.payload.get("event")
if isinstance(event, dict):
events.append(event)
if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id:
status = str(notification.payload.get("status") or "ok")
finished = True
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
self.harness.client.session_prompt(
message_id = self.harness.client.session_prompt(
self.id,
content_blocks,
on_notification=collect,
notification_subscription=subscription,
)
while not finished:
received = False
while True:
notification = subscription.next()
if not received:
if not _is_inbox_receipt(notification, self.id, message_id):
continue
received = True
collect(notification)
if (
notification.method == "session.status"
and notification.payload.get("sessionId") == self.id
and notification.payload.get("status") == "idle"
):
break
return TurnResult(
return RunResult(
session_id=self.id,
status=status,
final_response=final_response(events),
events=events,
notifications=notifications,
@@ -178,6 +180,19 @@ class Session:
)
def _is_inbox_receipt(notification: Notification, session_id: str, message_id: str) -> bool:
if notification.method != "session.event" or notification.payload.get("sessionId") != session_id:
return False
event = notification.payload.get("event")
if not isinstance(event, dict) or event.get("type") != "agent/inbox/spliced":
return False
data = event.get("data")
inserted = data.get("inserted") if isinstance(data, dict) else None
return isinstance(inserted, list) and any(
isinstance(message, dict) and message.get("id") == message_id for message in inserted
)
def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]:
if isinstance(input, str):
return [{"type": "text", "text": input}]
+5 -4
View File
@@ -10,7 +10,7 @@ import uuid
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Literal, TypeAlias, TypeVar
from typing import Callable, TypeAlias, TypeVar
from pydantic import BaseModel
@@ -142,9 +142,9 @@ class HarnessClient:
*,
on_notification: Callable[[Notification], None] | None = None,
notification_subscription: "NotificationSubscription | None" = None,
) -> None:
) -> str:
payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks}
self.request(
response = self.request(
"session/prompt",
payload,
response_model=_SessionPromptResponse,
@@ -152,6 +152,7 @@ class HarnessClient:
notification_filter=self._notification_belongs_to_session_tree(session_id),
notification_subscription=notification_subscription,
)
return response.messageId
def request(
self,
@@ -536,7 +537,7 @@ class NotificationSubscription:
class _SessionPromptResponse(BaseModel):
accepted: Literal[True]
messageId: str
class _ShutdownResponse(BaseModel):