managed-agent-sdk 0.0.2__tar.gz → 0.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/CHANGELOG.md +53 -0
  2. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/PKG-INFO +29 -17
  3. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/README.md +28 -16
  4. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/__init__.py +11 -0
  5. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_version.py +1 -1
  6. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/client.py +107 -55
  7. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/compat.py +61 -18
  8. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/transport.py +230 -216
  9. managed_agent_sdk-0.0.3/src/managed_agent_sdk/acp_types.py +181 -0
  10. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/agent.py +18 -21
  11. managed_agent_sdk-0.0.3/src/managed_agent_sdk/auth.py +185 -0
  12. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/capi.py +112 -52
  13. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/catalog.py +3 -9
  14. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/client.py +89 -13
  15. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/external_agent.py +7 -15
  16. managed_agent_sdk-0.0.3/src/managed_agent_sdk/log_file.py +347 -0
  17. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/manifest.py +2 -5
  18. managed_agent_sdk-0.0.3/src/managed_agent_sdk/opts.py +533 -0
  19. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/session.py +287 -27
  20. managed_agent_sdk-0.0.3/src/managed_agent_sdk/telemetry.py +552 -0
  21. managed_agent_sdk-0.0.3/src/managed_agent_sdk/trace_context.py +74 -0
  22. managed_agent_sdk-0.0.2/src/managed_agent_sdk/acp_types.py +0 -93
  23. managed_agent_sdk-0.0.2/src/managed_agent_sdk/auth.py +0 -115
  24. managed_agent_sdk-0.0.2/src/managed_agent_sdk/opts.py +0 -299
  25. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/.gitignore +0 -0
  26. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/LICENSE +0 -0
  27. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/pyproject.toml +0 -0
  28. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/__init__.py +0 -0
  29. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/actions.py +0 -0
  30. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/defaults.py +0 -0
  31. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/enums.py +0 -0
  32. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/types.py +0 -0
  33. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/__init__.py +0 -0
  34. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/errors.py +0 -0
  35. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/py.typed +0 -0
  36. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/types.py +0 -0
  37. {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/version.py +0 -0
@@ -6,6 +6,59 @@ managed-agent-sdk(Python)的所有重要更新都会记录在这里。
6
6
 
7
7
  ## [未发布]
8
8
 
9
+ ## [0.0.3] - 2026-10-09
10
+
11
+ ### 🔒 安全
12
+
13
+ - 不再泄漏 SecretId 与请求签名 —— 官方 SDK 会在 `DEBUG` 级别把完整
14
+ `Authorization` 头打进日志,现已将其 logger(`tencentcloud_sdk_common`)
15
+ 钉在 `WARNING`。确需查看原始请求时,显式对该 logger 设 `DEBUG` 即可
16
+
17
+ ### ⚠️ 不兼容变更
18
+
19
+ - 日志配置合并为 `LoggerOpts(enable, level, sink, file)`,删除
20
+ `ClientOpts` 上原有的 `log_level` 字段,`logger` 的类型由 `Logger` 改为 `LoggerOpts`。原写法
21
+ `ClientOpts(logger=..., log_level="debug")` 改为
22
+ `ClientOpts(logger=LoggerOpts(level="debug"))`
23
+ - 控制台日志**默认开启**(`warn` 级别),原先默认静默。用
24
+ `LoggerOpts(enable=False)` 关闭;关闭后即使宿主配置了 root logger 也不输出
25
+
26
+ ### ✨ 新增
27
+
28
+ - `prompt()` 返回值新增 `refusal`,携带服务端给出的结构化拒绝原因;
29
+ 同时导出 `RefusalReason` 类型
30
+ - `prompt()` 返回值新增 `trace_id`(本轮 W3C trace id)与 `usage`(本轮
31
+ token 消耗);入参新增 `trace_id`,不传时自动生成,每轮通过
32
+ `traceparent` 请求头传给服务端。导出 `UsageInfo` 类型
33
+ - 文件日志:配置 `LoggerOpts(file=LogFileOpts())` 后写入
34
+ `.wma-log/sdk-debug.log`,与控制台分别设置级别;凭据自动脱敏,按大小
35
+ 轮转、按天清理
36
+ - 接入伽利略上报:记录对话轮次的开始、结束、失败等事件,默认开启,可用
37
+ `TelemetryOpts(enabled=False)` 关闭。`aclose()` 会等待上报发送完成
38
+
39
+ ### 🐛 修复
40
+
41
+ - 收到 401 时作废 token 缓存,不再反复用同一张已失效的票重试
42
+ - `invalidate()` 补上世代守卫:换票在途时作废,其结果不再写回缓存
43
+
44
+ ### ✨ 优化
45
+
46
+ - 补齐运行期日志:ACP 数据面(建连、重连、心跳、SSE)与 OneID 换票层
47
+ 此前均无日志
48
+ - 数据面日志改走 `ClientOpts.logger`,`log_level` 对其生效;传了 `logger`
49
+ 未指定级别时按 `"warn"` 兜底
50
+ - 控制面日志带上 TraceId,贯穿请求 / 响应 / 错误三条日志,并写入
51
+ `X-TC-TraceId` 请求头
52
+ - 日志降噪:连接 ID 截断显示,不再逐次记录 token 缓存命中
53
+ - 更正 `aclose()` 的文档说明 —— 它当前是空实现,不调用不会漏资源
54
+
55
+ ### 📖 文档
56
+
57
+ - 快速开始简化为 3 步,去掉多余的 `versions.create()`
58
+ - 新增「回复文本怎么拿」「版本要不要自己建」两节
59
+
60
+ ## [0.0.2] - 2026-09-22
61
+
9
62
  ### ✨ 优化
10
63
 
11
64
  - 优化部分功能
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: managed-agent-sdk
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Managed Agent SDK (Python) — 控制面走腾讯云 API,数据面走 ACP
5
5
  Project-URL: Homepage, https://cnb.cool/codebuddy/managed-agent-doc/-/tree/main/python
6
6
  Project-URL: Documentation, https://cnb.cool/codebuddy/managed-agent-doc/-/blob/main/python/api.md
@@ -67,15 +67,15 @@ import asyncio
67
67
  from managed_agent_sdk import (
68
68
  CreateAgentOpts,
69
69
  CreateSessionOpts,
70
- CreateVersionOpts,
71
70
  ManagedAgentClient,
72
71
  ManifestBuilder,
72
+ PromptOpts,
73
73
  )
74
74
 
75
75
 
76
76
  async def main() -> None:
77
77
  async with ManagedAgentClient() as client:
78
- # 创建 Agent
78
+ # 1. 创建 Agent —— 服务端顺带建好承载 manifest 的 default 版本
79
79
  agent = await client.agents.create(
80
80
  CreateAgentOpts(
81
81
  agent_name="日志分析助手",
@@ -84,21 +84,17 @@ async def main() -> None:
84
84
  )
85
85
  )
86
86
 
87
- # 创建版本(manifest 服务端必填)
88
- version = await agent.versions.create(
89
- CreateVersionOpts(
90
- manifest=ManifestBuilder().system_prompt("你是日志分析专家").build(),
91
- )
92
- )
87
+ # 2. 创建会话(拉起真实沙箱,不会自动回收 —— 尽量复用)
88
+ session = await agent.sessions.create(CreateSessionOpts())
93
89
 
94
- # 创建会话(拉起真实沙箱,不会自动回收 —— 尽量复用)
95
- session = await agent.sessions.create(
96
- CreateSessionOpts(version_id=version.version_id)
97
- )
90
+ # 3. 对话。回复文本走 on_chunk,见下方说明
91
+ def on_chunk(n) -> None:
92
+ u = n.update
93
+ if u.sessionUpdate == "agent_message_chunk" and u.content.type == "text":
94
+ print(u.content.text, end="", flush=True)
98
95
 
99
- # 对话
100
- res = await session.prompt("分析这份日志")
101
- print(res.stop_reason)
96
+ res = await session.prompt("分析这份日志", PromptOpts(on_chunk=on_chunk))
97
+ print(f"\nstop_reason: {res.stop_reason}")
102
98
 
103
99
  await session.disconnect()
104
100
 
@@ -106,7 +102,23 @@ async def main() -> None:
106
102
  asyncio.run(main())
107
103
  ```
108
104
 
109
- `prompt()` 的返回值只携带 `stop_reason`,回复文本从 `on_chunk` 取 —— 流式内容与终局结果分两条通道,这是 ACP 的协议设计。
105
+ ### 回复文本怎么拿
106
+
107
+ `prompt()` 的返回值只携带 `stop_reason` 与 `refusal`,**不含回复正文** ——
108
+ 流式内容与终局结果分两条通道,这是 ACP 的协议设计。两条路都能拿到正文:
109
+
110
+ - **`on_chunk`**(上面的写法):实时增量,适合边生成边展示
111
+ - **`session.messages()`**:轮次结束后按 `EventType == "ASSISTANT"` 取 `Message["Content"]`,
112
+ 适合只要最终结果、不关心过程
113
+
114
+ ### 版本要不要自己建
115
+
116
+ 入门不需要。`agents.create()` 传了 `manifest` / `model` 时,服务端会建好一个
117
+ `default` 版本承载它们;`sessions.create()` 不传 `version_id` 时按 Agent 的
118
+ routing 配置分流,也会落到它。
119
+
120
+ 要灰度多个配置(A/B、逐步放量)时才用 `agent.versions.create()` 显式建版本,
121
+ 再配合 `agent.update_routing()` 分流。
110
122
 
111
123
  ## 配置连接器 / 技能 / 专家
112
124
 
@@ -29,15 +29,15 @@ import asyncio
29
29
  from managed_agent_sdk import (
30
30
  CreateAgentOpts,
31
31
  CreateSessionOpts,
32
- CreateVersionOpts,
33
32
  ManagedAgentClient,
34
33
  ManifestBuilder,
34
+ PromptOpts,
35
35
  )
36
36
 
37
37
 
38
38
  async def main() -> None:
39
39
  async with ManagedAgentClient() as client:
40
- # 创建 Agent
40
+ # 1. 创建 Agent —— 服务端顺带建好承载 manifest 的 default 版本
41
41
  agent = await client.agents.create(
42
42
  CreateAgentOpts(
43
43
  agent_name="日志分析助手",
@@ -46,21 +46,17 @@ async def main() -> None:
46
46
  )
47
47
  )
48
48
 
49
- # 创建版本(manifest 服务端必填)
50
- version = await agent.versions.create(
51
- CreateVersionOpts(
52
- manifest=ManifestBuilder().system_prompt("你是日志分析专家").build(),
53
- )
54
- )
49
+ # 2. 创建会话(拉起真实沙箱,不会自动回收 —— 尽量复用)
50
+ session = await agent.sessions.create(CreateSessionOpts())
55
51
 
56
- # 创建会话(拉起真实沙箱,不会自动回收 —— 尽量复用)
57
- session = await agent.sessions.create(
58
- CreateSessionOpts(version_id=version.version_id)
59
- )
52
+ # 3. 对话。回复文本走 on_chunk,见下方说明
53
+ def on_chunk(n) -> None:
54
+ u = n.update
55
+ if u.sessionUpdate == "agent_message_chunk" and u.content.type == "text":
56
+ print(u.content.text, end="", flush=True)
60
57
 
61
- # 对话
62
- res = await session.prompt("分析这份日志")
63
- print(res.stop_reason)
58
+ res = await session.prompt("分析这份日志", PromptOpts(on_chunk=on_chunk))
59
+ print(f"\nstop_reason: {res.stop_reason}")
64
60
 
65
61
  await session.disconnect()
66
62
 
@@ -68,7 +64,23 @@ async def main() -> None:
68
64
  asyncio.run(main())
69
65
  ```
70
66
 
71
- `prompt()` 的返回值只携带 `stop_reason`,回复文本从 `on_chunk` 取 —— 流式内容与终局结果分两条通道,这是 ACP 的协议设计。
67
+ ### 回复文本怎么拿
68
+
69
+ `prompt()` 的返回值只携带 `stop_reason` 与 `refusal`,**不含回复正文** ——
70
+ 流式内容与终局结果分两条通道,这是 ACP 的协议设计。两条路都能拿到正文:
71
+
72
+ - **`on_chunk`**(上面的写法):实时增量,适合边生成边展示
73
+ - **`session.messages()`**:轮次结束后按 `EventType == "ASSISTANT"` 取 `Message["Content"]`,
74
+ 适合只要最终结果、不关心过程
75
+
76
+ ### 版本要不要自己建
77
+
78
+ 入门不需要。`agents.create()` 传了 `manifest` / `model` 时,服务端会建好一个
79
+ `default` 版本承载它们;`sessions.create()` 不传 `version_id` 时按 Agent 的
80
+ routing 配置分流,也会落到它。
81
+
82
+ 要灰度多个配置(A/B、逐步放量)时才用 `agent.versions.create()` 显式建版本,
83
+ 再配合 `agent.update_routing()` 分流。
72
84
 
73
85
  ## 配置连接器 / 技能 / 专家
74
86
 
@@ -49,9 +49,11 @@ from .acp_types import (
49
49
  EmbeddedResource,
50
50
  ImageContent,
51
51
  PromptResponse,
52
+ RefusalReason,
52
53
  ResourceLink,
53
54
  SessionNotification,
54
55
  TextContent,
56
+ UsageInfo,
55
57
  )
56
58
  from .agent import (
57
59
  Agent,
@@ -129,7 +131,9 @@ from .opts import (
129
131
  Credentials,
130
132
  Filter,
131
133
  Language,
134
+ LogFileOpts,
132
135
  Logger,
136
+ LoggerOpts,
133
137
  LogLevel,
134
138
  Page,
135
139
  PageOpts,
@@ -139,6 +143,7 @@ from .opts import (
139
143
  SortDirection,
140
144
  )
141
145
  from .session import MessageListOpts, PromptOpts, Session
146
+ from .telemetry import TelemetryOpts, TurnTelemetryOpts
142
147
  from .types import (
143
148
  A2AConfig,
144
149
  A2ASkillItem,
@@ -194,6 +199,7 @@ __all__ = [
194
199
  "ResourceLink",
195
200
  "EmbeddedResource",
196
201
  "PromptResponse",
202
+ "RefusalReason",
197
203
  "SessionNotification",
198
204
  "AcpConnectionState",
199
205
  "NotificationListener",
@@ -226,6 +232,8 @@ __all__ = [
226
232
  "UpdateVersionOpts",
227
233
  "MessageListOpts",
228
234
  "PromptOpts",
235
+ "TelemetryOpts",
236
+ "TurnTelemetryOpts",
229
237
  "A2ASkill",
230
238
  "ListConnectorsOpts",
231
239
  "ListSkillsOpts",
@@ -296,8 +304,11 @@ __all__ = [
296
304
  "Filter",
297
305
  "Credentials",
298
306
  "Logger",
307
+ "LogFileOpts",
308
+ "LoggerOpts",
299
309
  "LogLevel",
300
310
  "Language",
311
+ "UsageInfo",
301
312
  # ── 错误 ────────────────────────────────
302
313
  "CloudAgentError",
303
314
  "ValidationError",
@@ -8,7 +8,7 @@
8
8
 
9
9
  import platform
10
10
 
11
- __version__ = "0.0.2"
11
+ __version__ = "0.0.3"
12
12
 
13
13
  #: 请求头 ``User-Agent`` 的值。
14
14
  #:
@@ -21,7 +21,13 @@ import logging
21
21
  from collections import defaultdict, deque
22
22
  from collections.abc import Awaitable, Callable
23
23
  from dataclasses import dataclass
24
- from typing import TYPE_CHECKING, Any, Literal
24
+ from typing import TYPE_CHECKING, Any, Literal, cast
25
+
26
+ from acp import Client
27
+ from acp.client import ClientSideConnection
28
+ from acp.exceptions import RequestError
29
+ from acp.schema import DeniedOutcome, RequestPermissionResponse, SessionNotification
30
+ from pydantic import ValidationError as PydanticValidationError
25
31
 
26
32
  from managed_agent_sdk._generated.defaults import (
27
33
  ACP_HEARTBEAT_TIMEOUT_MS,
@@ -37,9 +43,17 @@ from managed_agent_sdk.acp.transport import (
37
43
  TransportOptions,
38
44
  )
39
45
  from managed_agent_sdk.errors import AcpAuthError, AcpNetworkError, AcpProtocolError
46
+ from managed_agent_sdk.opts import (
47
+ FileSinkLike,
48
+ LeveledLogger,
49
+ Logger,
50
+ LogLevel,
51
+ short_conn_id,
52
+ )
53
+ from managed_agent_sdk.telemetry import AcpPostInfo, AcpSseConnectInfo
40
54
 
41
55
  if TYPE_CHECKING:
42
- from acp.schema import PromptResponse, SessionNotification
56
+ from acp.schema import PromptResponse
43
57
 
44
58
  from managed_agent_sdk.opts import ConnectOpts
45
59
 
@@ -108,33 +122,35 @@ class _NotificationRouter:
108
122
  self,
109
123
  dispatch: Callable[[Any], None],
110
124
  get_connection_id: Callable[[], str | None] | None = None,
125
+ log: LeveledLogger | None = None,
111
126
  ) -> None:
112
127
  self._dispatch = dispatch
113
128
  self._get_connection_id = get_connection_id
129
+ # 未注入时退化为「模块级 logger + 不过滤」,与 LeveledLogger 的回退一致
130
+ self._log = log if log is not None else LeveledLogger(None, None)
114
131
 
115
132
  def _conn_tag(self) -> str:
116
- """返回当前 connection_id(日志前缀用);未连接时返回 ``-``。"""
133
+ """返回当前 connection_id 的短形式(日志前缀用);未连接时返回 ``-``。
134
+
135
+ 截断到 12 位:完整 ID 是 141 字符的 base64,会把日志行撑到 180+ 字符。
136
+ 需要完整值时看传输层建连那条 ``connected (full id: …)``。
137
+ """
117
138
  if self._get_connection_id is None:
118
139
  return "-"
119
- cid = self._get_connection_id()
120
- return cid if cid else "-"
140
+ return short_conn_id(self._get_connection_id())
121
141
 
122
- async def session_update(
123
- self, session_id: str, update: Any, **_kwargs: Any
124
- ) -> None:
142
+ async def session_update(self, session_id: str, update: Any, **_kwargs: Any) -> None:
125
143
  """每条 session/update notification 都走这里。
126
144
 
127
145
  注意:官方 router 已经把 payload 展开为字段,这里重新组装成一个
128
146
  ``SessionNotification`` 实例供用户订阅者消费(保留 Pydantic 类型安全)。
129
147
  """
130
- from acp.schema import SessionNotification
131
-
132
148
  notification = SessionNotification(session_id=session_id, update=update)
133
149
  self._dispatch(notification)
134
150
 
135
151
  async def ext_notification(self, method: str, params: Any) -> None:
136
152
  """扩展通知(如 _codebuddy.ai/* )—— 当前忽略。"""
137
- logger.debug(
153
+ self._log.debug(
138
154
  "[acp conn=%s] ignore ext_notification: method=%s",
139
155
  self._conn_tag(),
140
156
  method,
@@ -151,9 +167,7 @@ class _NotificationRouter:
151
167
 
152
168
  async def request_permission(self, **_kwargs: Any) -> Any:
153
169
  """服务端发 request_permission 时返回 "用户取消"(协议合法拒绝)。"""
154
- from acp.schema import DeniedOutcome, RequestPermissionResponse
155
-
156
- logger.debug(
170
+ self._log.debug(
157
171
  "[acp conn=%s] request_permission received but no permission handler configured "
158
172
  "— responding with cancelled outcome",
159
173
  self._conn_tag(),
@@ -187,8 +201,6 @@ class _NotificationRouter:
187
201
 
188
202
  def _raise_method_not_found(method: str) -> None:
189
203
  """抛 ACP 标准的 ``-32601 method not found``(由协议包翻成 JSON-RPC error)。"""
190
- from acp.exceptions import RequestError
191
-
192
204
  raise RequestError.method_not_found(method)
193
205
 
194
206
 
@@ -212,22 +224,24 @@ class RawSessionNotification:
212
224
  """原始 update 负载(dict)。"""
213
225
 
214
226
 
215
- def _parse_session_notification(params: dict[str, Any], session_id: str) -> Any:
227
+ def _parse_session_notification(
228
+ params: dict[str, Any], session_id: str, log: LeveledLogger | None = None
229
+ ) -> Any:
216
230
  """把 ``session/update`` 的 params 解析成通知对象。
217
231
 
218
232
  已知 ``sessionUpdate`` 取值产出 :class:`acp.schema.SessionNotification`,
219
233
  与协议包自己解析的结果完全一致;未知取值降级为
220
234
  :class:`RawSessionNotification`,不丢消息。
221
235
  """
222
- from acp.schema import SessionNotification
223
- from pydantic import ValidationError
224
-
225
236
  try:
226
237
  return SessionNotification.model_validate(params)
227
- except ValidationError:
228
- logger.debug(
229
- "[acp] session/update with unrecognized payload, delivering raw: %r",
230
- params.get("update"),
238
+ except PydanticValidationError:
239
+ # 只记类型名,不记 payload —— update 里有用户对话正文
240
+ update = params.get("update")
241
+ kind = update.get("sessionUpdate") if isinstance(update, dict) else None
242
+ (log or LeveledLogger(None, None)).debug(
243
+ "[acp] session/update unrecognized (sessionUpdate=%s), delivering raw",
244
+ kind or "?",
231
245
  )
232
246
  return RawSessionNotification(session_id=session_id, update=params.get("update"))
233
247
 
@@ -241,20 +255,35 @@ class AcpClient:
241
255
  def __init__(
242
256
  self,
243
257
  *,
244
- client_logger: Any = None,
258
+ client_logger: Logger | None = None,
259
+ log_level: LogLevel | None = None,
260
+ file_sink: FileSinkLike | None = None,
245
261
  transport: Any = None,
262
+ on_post_complete: Callable[[AcpPostInfo], None] | None = None,
263
+ on_sse_connect_attempt: Callable[[AcpSseConnectInfo], None] | None = None,
246
264
  ) -> None:
247
265
  """构造 ACP 客户端。
248
266
 
249
267
  Args:
250
268
  client_logger: 调用方经 ``ClientOpts.logger`` 传入的日志器。
251
- 为 ``None`` 时用模块级 ``logging.getLogger("managed_agent_sdk")``。
269
+ 为 ``None`` 时回退模块级 ``logging.getLogger("managed_agent_sdk")``。
252
270
  对应 Node 侧把 logger 透传给 ``AcpClient`` 的做法 —— 缺了它,
253
271
  ``ClientOpts.logger`` 对数据面完全无效。
272
+ log_level: 日志级别,经 ``ClientOpts.log_level`` 传入。仅在显式注入
273
+ ``client_logger`` 时生效 —— 回退到模块级 logger 时交给宿主的
274
+ logging 配置,不在这里二次过滤。
275
+ file_sink: 诊断日志文件出口,经 ``ClientOpts.logger.file`` 开启时由
276
+ ``ManagedAgentClient`` 注入。与控制台各自判级,互不影响。
254
277
  transport: 替换 HTTP 传输层(``httpx.AsyncBaseTransport``),
255
278
  对应 Node 侧的 ``ClientOpts.fetch``。
256
279
  """
257
- self._logger: Any = client_logger if client_logger is not None else logger
280
+ self._log_level = log_level
281
+ self._client_logger = client_logger
282
+ self._file_sink = file_sink
283
+ self._logger = LeveledLogger(client_logger, log_level, file_sink)
284
+ # 埋点路径 span 的数据源,原样交给传输层
285
+ self._on_post_complete = on_post_complete
286
+ self._on_sse_connect_attempt = on_sse_connect_attempt
258
287
  self._transport_override = transport
259
288
  self._state: AcpConnectionState = "INITIAL"
260
289
 
@@ -263,6 +292,8 @@ class AcpClient:
263
292
  self._initialize_result: Any = None
264
293
 
265
294
  self._token_provider: Callable[[], Awaitable[str]] | None = None
295
+ #: 作废 token 缓存 —— 传输层收到 401 时调用,与 ``_token_provider`` 同源。
296
+ self._token_invalidator: Callable[[], None] | None = None
266
297
 
267
298
  # session_id -> set[listener]
268
299
  self._subscribers: dict[str, set[NotificationListener]] = defaultdict(set)
@@ -295,14 +326,24 @@ class AcpClient:
295
326
  def initialized(self) -> bool:
296
327
  return self._initialize_result is not None
297
328
 
298
- def set_token_provider(self, fn: Callable[[], Awaitable[str]]) -> None:
299
- """注入取 token 函数。
329
+ def set_token_provider(
330
+ self,
331
+ fn: Callable[[], Awaitable[str]],
332
+ invalidate: Callable[[], None] | None = None,
333
+ ) -> None:
334
+ """注入取 token 与作废回调。
300
335
 
301
- 传输层**每次请求**(建连 / 重连 / POST / DELETE)前调用;实现方负责
302
- 缓存与临期刷新。必须每次都取:OneID token 有效期 30 天,长连接会
336
+ 传输层**每次请求**(建连 / 重连 / POST / DELETE)前调用 ``fn``;实现方
337
+ 负责缓存与临期刷新。必须每次都取:OneID token 有效期 30 天,长连接会
303
338
  跨过它,只在建连时取一次的话过期后所有请求都带着废票发。
339
+
340
+ ``invalidate`` 在传输层收到 **401** 时调用,与 ``fn`` 同源:401 说明那张
341
+ 票已被服务端拒,而它在本地可能还没到期 —— 不作废的话 ``fn()`` 会一直
342
+ 命中缓存返回废票,401 反复出现直到本地有效期走完。静态 token 场景没有
343
+ 缓存可作废,可以省略。
304
344
  """
305
345
  self._token_provider = fn
346
+ self._token_invalidator = invalidate
306
347
 
307
348
  def _conn_tag(self) -> str:
308
349
  """返回当前 connection_id(日志前缀用);未连接时返回 ``-``。"""
@@ -318,6 +359,7 @@ class AcpClient:
318
359
  opts: ConnectOpts | None = None,
319
360
  *,
320
361
  http_client: Any = None,
362
+ ephemeral_headers: dict[str, str] | None = None,
321
363
  ) -> None:
322
364
  """建立 ACP 连接。
323
365
 
@@ -331,16 +373,12 @@ class AcpClient:
331
373
  self._state = "CONNECTING"
332
374
 
333
375
  # 从 ConnectOpts 读重连 / 心跳参数(默认值来自 spec/defaults.yaml)
334
- heartbeat_ms = (
335
- opts.heartbeat_timeout_ms if opts is not None else ACP_HEARTBEAT_TIMEOUT_MS
336
- )
376
+ heartbeat_ms = opts.heartbeat_timeout_ms if opts is not None else ACP_HEARTBEAT_TIMEOUT_MS
337
377
  reconnect_init_ms = (
338
378
  opts.reconnect_backoff_ms if opts is not None else ACP_RECONNECT_BACKOFF_MS
339
379
  )
340
380
  reconnect_max_ms = (
341
- opts.reconnect_max_backoff_ms
342
- if opts is not None
343
- else ACP_RECONNECT_MAX_BACKOFF_MS
381
+ opts.reconnect_max_backoff_ms if opts is not None else ACP_RECONNECT_MAX_BACKOFF_MS
344
382
  )
345
383
  reconnect_max = opts.reconnect_max_attempts if opts is not None else float("inf")
346
384
 
@@ -351,11 +389,19 @@ class AcpClient:
351
389
  endpoint=acp_url,
352
390
  auth_token=token,
353
391
  get_token=self._token_provider,
392
+ invalidate_token=self._token_invalidator,
354
393
  # UA 在前:调用方显式传的同名头优先
355
394
  headers={
356
395
  "User-Agent": USER_AGENT,
357
396
  **(dict(opts.headers) if opts is not None and opts.headers else {}),
358
397
  },
398
+ logger=self._client_logger,
399
+ log_level=self._log_level,
400
+ file_sink=self._file_sink,
401
+ # 建连发生在某一轮 prompt 内时,本轮 trace 从第一个 GET 就带上
402
+ ephemeral_headers=ephemeral_headers,
403
+ on_post_complete=self._on_post_complete,
404
+ on_sse_connect_attempt=self._on_sse_connect_attempt,
359
405
  heartbeat_timeout_ms=heartbeat_ms,
360
406
  reconnect_initial_delay_ms=reconnect_init_ms,
361
407
  reconnect_max_delay_ms=reconnect_max_ms,
@@ -372,6 +418,7 @@ class AcpClient:
372
418
  await transport.start()
373
419
  except TransportConnectError as e:
374
420
  self._state = "CLOSED"
421
+ await transport.aclose()
375
422
  # 状态码与 x-request-id 结构化带出,鉴权类单独成型 ——
376
423
  # 调用方据此决定是换票重连还是直接失败
377
424
  err_cls = AcpAuthError if e.non_retryable else AcpNetworkError
@@ -383,20 +430,13 @@ class AcpClient:
383
430
  ) from e
384
431
 
385
432
  # 连上 SSE → 构造官方 ClientSideConnection
386
- # 运行时 import 避免 circular / 未装 agent-client-protocol 时 crash 太早
387
- from typing import cast
388
-
389
- from acp import Client
390
- from acp.client import ClientSideConnection
391
-
392
433
  router = _NotificationRouter(
393
434
  self._dispatch_notification,
394
435
  get_connection_id=lambda: transport.connection_id,
436
+ log=self._logger,
395
437
  )
396
438
  try:
397
- conn = ClientSideConnection(
398
- cast(Client, router), transport.writer, transport.reader
399
- )
439
+ conn = ClientSideConnection(cast(Client, router), transport)
400
440
  except Exception as e:
401
441
  self._state = "CLOSED"
402
442
  await transport.aclose()
@@ -433,9 +473,7 @@ class AcpClient:
433
473
  try:
434
474
  await self._conn.close()
435
475
  except Exception as e:
436
- self._logger.debug(
437
- "[acp conn=%s] conn.close error (ignored): %s", last_conn_id, e
438
- )
476
+ self._logger.debug("[acp conn=%s] conn.close error (ignored): %s", last_conn_id, e)
439
477
  self._conn = None
440
478
 
441
479
  if self._transport is not None:
@@ -464,8 +502,12 @@ class AcpClient:
464
502
  raise AcpProtocolError("Not connected")
465
503
  if session_id in self._loaded_sessions:
466
504
  return
467
- await self._conn.load_session(cwd=cwd, session_id=session_id, mcp_servers=[])
468
- self._loaded_sessions.add(session_id)
505
+ try:
506
+ await self._conn.load_session(cwd=cwd, session_id=session_id, mcp_servers=[])
507
+ self._loaded_sessions.add(session_id)
508
+ finally:
509
+ if self._transport is not None:
510
+ self._transport.set_ephemeral_headers(None)
469
511
 
470
512
  async def prompt(
471
513
  self,
@@ -473,18 +515,28 @@ class AcpClient:
473
515
  blocks: list[Any],
474
516
  *,
475
517
  cancel_event: asyncio.Event | None = None,
518
+ traceparent: str | None = None,
476
519
  ) -> PromptResponse:
477
520
  """发 prompt。
478
521
 
479
522
  - 本次 prompt 期间的 notifications 由 ``subscribe()`` 订阅器独立接收
480
523
  - 同 session_id 的多次 prompt 会排队(ACP 协议要求)
481
524
  - ``cancel_event.set()`` 或 ``task.cancel()`` 触发时向服务端发 ``session/cancel``
525
+ - ``traceparent`` 经传输层随本轮所有 POST 发出(同轮 cancel 也带上)
482
526
  """
483
527
  if self._conn is None:
484
528
  raise AcpProtocolError("Not connected")
485
529
 
486
530
  lock = self._prompt_locks[session_id]
487
531
  async with lock:
532
+ if cancel_event is not None and cancel_event.is_set():
533
+ raise asyncio.CancelledError()
534
+ if self._transport is not None:
535
+ self._transport.set_ephemeral_headers(
536
+ {"traceparent": traceparent, "X-Trace-Id": traceparent.split("-")[1]}
537
+ if traceparent
538
+ else None
539
+ )
488
540
  self._active_prompts.add(session_id)
489
541
  try:
490
542
  # 构造主任务:调官方 prompt RPC
@@ -535,6 +587,8 @@ class AcpClient:
535
587
  return response # type: ignore[no-any-return]
536
588
  finally:
537
589
  self._active_prompts.discard(session_id)
590
+ if self._transport is not None:
591
+ self._transport.set_ephemeral_headers(None)
538
592
 
539
593
  async def session_cancel(self, session_id: str) -> None:
540
594
  """发 session/cancel 通知(幂等,尽力而为)。"""
@@ -596,9 +650,7 @@ class AcpClient:
596
650
 
597
651
  # ─── Notification 订阅 ────────────────────────────────
598
652
 
599
- def subscribe(
600
- self, session_id: str, listener: NotificationListener
601
- ) -> Callable[[], None]:
653
+ def subscribe(self, session_id: str, listener: NotificationListener) -> Callable[[], None]:
602
654
  """订阅指定 session 的 notifications。返回 unsubscribe 函数。
603
655
 
604
656
  一个 session 可以有多个 listener,按注册顺序同步调用(异常静默吞)。
@@ -665,7 +717,7 @@ class AcpClient:
665
717
  if not isinstance(session_id, str):
666
718
  return False
667
719
 
668
- self._dispatch_notification(_parse_session_notification(params, session_id))
720
+ self._dispatch_notification(_parse_session_notification(params, session_id, self._logger))
669
721
  return True
670
722
 
671
723
  # ─── 连接级事件 ────────────────────────────────