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.
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/CHANGELOG.md +53 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/PKG-INFO +29 -17
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/README.md +28 -16
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/__init__.py +11 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_version.py +1 -1
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/client.py +107 -55
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/compat.py +61 -18
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/transport.py +230 -216
- managed_agent_sdk-0.0.3/src/managed_agent_sdk/acp_types.py +181 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/agent.py +18 -21
- managed_agent_sdk-0.0.3/src/managed_agent_sdk/auth.py +185 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/capi.py +112 -52
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/catalog.py +3 -9
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/client.py +89 -13
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/external_agent.py +7 -15
- managed_agent_sdk-0.0.3/src/managed_agent_sdk/log_file.py +347 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/manifest.py +2 -5
- managed_agent_sdk-0.0.3/src/managed_agent_sdk/opts.py +533 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/session.py +287 -27
- managed_agent_sdk-0.0.3/src/managed_agent_sdk/telemetry.py +552 -0
- managed_agent_sdk-0.0.3/src/managed_agent_sdk/trace_context.py +74 -0
- managed_agent_sdk-0.0.2/src/managed_agent_sdk/acp_types.py +0 -93
- managed_agent_sdk-0.0.2/src/managed_agent_sdk/auth.py +0 -115
- managed_agent_sdk-0.0.2/src/managed_agent_sdk/opts.py +0 -299
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/.gitignore +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/LICENSE +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/pyproject.toml +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/__init__.py +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/actions.py +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/defaults.py +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/enums.py +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/_generated/types.py +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/acp/__init__.py +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/errors.py +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/py.typed +0 -0
- {managed_agent_sdk-0.0.2 → managed_agent_sdk-0.0.3}/src/managed_agent_sdk/types.py +0 -0
- {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.
|
|
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
|
-
#
|
|
88
|
-
|
|
89
|
-
CreateVersionOpts(
|
|
90
|
-
manifest=ManifestBuilder().system_prompt("你是日志分析专家").build(),
|
|
91
|
-
)
|
|
92
|
-
)
|
|
87
|
+
# 2. 创建会话(拉起真实沙箱,不会自动回收 —— 尽量复用)
|
|
88
|
+
session = await agent.sessions.create(CreateSessionOpts())
|
|
93
89
|
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
50
|
-
|
|
51
|
-
CreateVersionOpts(
|
|
52
|
-
manifest=ManifestBuilder().system_prompt("你是日志分析专家").build(),
|
|
53
|
-
)
|
|
54
|
-
)
|
|
49
|
+
# 2. 创建会话(拉起真实沙箱,不会自动回收 —— 尽量复用)
|
|
50
|
+
session = await agent.sessions.create(CreateSessionOpts())
|
|
55
51
|
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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",
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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:
|
|
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``
|
|
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.
|
|
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(
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
# ─── 连接级事件 ────────────────────────────────
|