zero-agent-core 0.2.0__py3-none-any.whl

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 (104) hide show
  1. agent_core/__init__.py +525 -0
  2. agent_core/access/__init__.py +6 -0
  3. agent_core/access/context.py +45 -0
  4. agent_core/access/guard.py +40 -0
  5. agent_core/acp/README.md +236 -0
  6. agent_core/acp/__init__.py +20 -0
  7. agent_core/acp/server.py +351 -0
  8. agent_core/acp/types.py +86 -0
  9. agent_core/callbacks/__init__.py +17 -0
  10. agent_core/callbacks/manager.py +105 -0
  11. agent_core/checkpoint/__init__.py +19 -0
  12. agent_core/checkpoint/naming.py +93 -0
  13. agent_core/checkpoint/store.py +186 -0
  14. agent_core/checkpoint/travel.py +448 -0
  15. agent_core/compaction/__init__.py +57 -0
  16. agent_core/compaction/middleware.py +428 -0
  17. agent_core/compaction/prompts.py +32 -0
  18. agent_core/compaction/pruner.py +121 -0
  19. agent_core/compaction/region.py +145 -0
  20. agent_core/compaction/spill.py +81 -0
  21. agent_core/compaction/spill_tool.py +42 -0
  22. agent_core/compaction/summarizer.py +95 -0
  23. agent_core/compaction/token_limit.py +74 -0
  24. agent_core/compaction/types.py +93 -0
  25. agent_core/docs.py +15 -0
  26. agent_core/documents/__init__.py +23 -0
  27. agent_core/documents/loaders.py +79 -0
  28. agent_core/documents/splitters.py +94 -0
  29. agent_core/documents/types.py +197 -0
  30. agent_core/errors.py +81 -0
  31. agent_core/guardrails/__init__.py +19 -0
  32. agent_core/guardrails/core.py +186 -0
  33. agent_core/hitl/__init__.py +15 -0
  34. agent_core/hitl/core.py +517 -0
  35. agent_core/mcp/__init__.py +14 -0
  36. agent_core/mcp/client.py +212 -0
  37. agent_core/mcp/errors.py +26 -0
  38. agent_core/middleware/__init__.py +26 -0
  39. agent_core/middleware/base.py +283 -0
  40. agent_core/model/__init__.py +73 -0
  41. agent_core/model/catalog.py +73 -0
  42. agent_core/model/core.py +179 -0
  43. agent_core/model/governance.py +317 -0
  44. agent_core/model/providers/__init__.py +43 -0
  45. agent_core/model/providers/anthropic.py +294 -0
  46. agent_core/model/providers/common.py +28 -0
  47. agent_core/model/providers/factory.py +107 -0
  48. agent_core/model/providers/gemini.py +273 -0
  49. agent_core/model/providers/ollama.py +80 -0
  50. agent_core/model/providers/openai_compatible.py +526 -0
  51. agent_core/model/structured.py +200 -0
  52. agent_core/observability/__init__.py +21 -0
  53. agent_core/observability/core.py +311 -0
  54. agent_core/ports/__init__.py +31 -0
  55. agent_core/ports/memory.py +130 -0
  56. agent_core/ports/model_selection.py +64 -0
  57. agent_core/ports/storage.py +114 -0
  58. agent_core/prompts/__init__.py +19 -0
  59. agent_core/prompts/registry.py +236 -0
  60. agent_core/prompts/templates.py +166 -0
  61. agent_core/protocol/__init__.py +31 -0
  62. agent_core/protocol/capabilities.py +49 -0
  63. agent_core/protocol/messages.py +100 -0
  64. agent_core/protocol/runtime.py +293 -0
  65. agent_core/py.typed +1 -0
  66. agent_core/retrieval/__init__.py +40 -0
  67. agent_core/retrieval/_common.py +121 -0
  68. agent_core/retrieval/chroma.py +269 -0
  69. agent_core/retrieval/core.py +63 -0
  70. agent_core/retrieval/factory.py +72 -0
  71. agent_core/retrieval/memory.py +232 -0
  72. agent_core/retrieval/pgvector.py +239 -0
  73. agent_core/retrieval/types.py +95 -0
  74. agent_core/runtime/__init__.py +28 -0
  75. agent_core/runtime/compat.py +98 -0
  76. agent_core/runtime/react.py +888 -0
  77. agent_core/runtime/service.py +557 -0
  78. agent_core/runtime/store.py +55 -0
  79. agent_core/serialization/__init__.py +21 -0
  80. agent_core/serialization/registry.py +318 -0
  81. agent_core/skills/__init__.py +24 -0
  82. agent_core/skills/errors.py +27 -0
  83. agent_core/skills/loader.py +258 -0
  84. agent_core/skills/registry.py +205 -0
  85. agent_core/skills/types.py +56 -0
  86. agent_core/storage/__init__.py +107 -0
  87. agent_core/storage/_sqlite_utils.py +92 -0
  88. agent_core/storage/context.py +382 -0
  89. agent_core/storage/lease.py +435 -0
  90. agent_core/storage/memory.py +604 -0
  91. agent_core/storage/model_selection.py +360 -0
  92. agent_core/storage/runtime.py +899 -0
  93. agent_core/storage/session.py +904 -0
  94. agent_core/storage/workflow.py +411 -0
  95. agent_core/tools/__init__.py +5 -0
  96. agent_core/tools/executor.py +306 -0
  97. agent_core/workflow/__init__.py +25 -0
  98. agent_core/workflow/durable.py +175 -0
  99. agent_core/workflow/state_machine.py +427 -0
  100. zero_agent_core-0.2.0.dist-info/METADATA +237 -0
  101. zero_agent_core-0.2.0.dist-info/RECORD +104 -0
  102. zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
  103. zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
  104. zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,236 @@
1
+ # ACP 模块说明
2
+
3
+ 本目录实现 Agent Client Protocol(ACP)的通用适配层。它不负责模型推理、Agent Loop、业务工具或
4
+ 持久化,而是负责把一个已经存在的 Agent Runtime 暴露给编辑器、CLI 或其他 ACP Client。
5
+
6
+ ## 1. ACP、MCP 和 Agent Runtime 的关系
7
+
8
+ ```text
9
+ ACP Client(编辑器 / CLI)
10
+ │
11
+ │ ACP:客户端调用 Agent
12
+ ▼
13
+ AcpStdioServer
14
+ │
15
+ │ AcpBackend:协议对象转换为应用调用
16
+ ▼
17
+ 应用 Agent Runtime(ReAct / LangGraph / 其他实现)
18
+ │
19
+ │ MCP:Agent 调用外部工具和资源
20
+ ▼
21
+ MCP Server
22
+ ```
23
+
24
+ - ACP 解决“外部客户端如何创建会话、发送提示、接收更新、审批和取消 Agent”。
25
+ - MCP 解决“Agent 如何发现并调用外部工具、资源和提示词”。
26
+ - `AcpBackend` 是两者之间的应用适配边界,不能把 ACP Client 提供的配置直接视为可信配置。
27
+
28
+ ## 2. 文件职责
29
+
30
+ | 文件 | 职责 |
31
+ | --- | --- |
32
+ | `types.py` | 定义 `AcpSession`、`AcpUpdate`、回调类型和应用必须实现的 `AcpBackend` 协议 |
33
+ | `server.py` | 实现基于 stdin/stdout 的双向 JSON-RPC 服务端、方法分发、更新通知、审批和取消 |
34
+ | `__init__.py` | 统一导出公共 API |
35
+
36
+ Core 只负责协议和传输。会话存储、模型调用、工具执行、访问控制和业务审批都由应用 Backend 负责。
37
+
38
+ ## 3. `types.py` 中的数据结构
39
+
40
+ ### `JsonObject`
41
+
42
+ `dict[str, Any]` 的可读别名,用于描述 JSON 对象。它不负责运行时校验;服务端会在协议入口校验
43
+ 关键字段,应用 Backend 仍需校验业务字段。
44
+
45
+ ### `AcpSession`
46
+
47
+ ACP 会话与应用会话的映射:
48
+
49
+ ```python
50
+ AcpSession(
51
+ session_id="acp-session-id",
52
+ cwd="E:/workspace",
53
+ metadata={"thread_id": "application-thread-id"},
54
+ )
55
+ ```
56
+
57
+ - `session_id` 是 ACP Client 后续请求使用的会话标识。
58
+ - `cwd` 是客户端声明的工作目录,仅是上下文输入,不能天然视为已授权目录。
59
+ - `metadata` 保存应用自己的 thread、用户、租户或运行参数映射。
60
+
61
+ `AcpSession` 本身不保存消息历史。历史是否持久化取决于具体 Backend。
62
+
63
+ ### `AcpUpdate`
64
+
65
+ 应用 Runtime 向协议层发送的标准化更新。Core 当前转换以下 `kind`:
66
+
67
+ | `kind` | ACP 更新 | 用途 |
68
+ | --- | --- | --- |
69
+ | `text` | `agent_message_chunk` | 可展示给用户的文本片段 |
70
+ | `thought` | `agent_thought_chunk` | 可选展示的运行过程说明 |
71
+ | `tool_started` | `tool_call` | 工具开始及输入 |
72
+ | `tool_finished` | `tool_call_update` | 工具结果和成功状态 |
73
+
74
+ `AcpUpdate.text()` 和 `AcpUpdate.thought()` 是常用构造方法。工具事件使用
75
+ `AcpUpdate(kind, payload)`,并应提供稳定的 `toolCallId`,使开始和结束事件能够对应。
76
+
77
+ ### `AcpEmitter` 与 `AcpPermissionRequester`
78
+
79
+ 它们是回调类型,不是未实现的函数:
80
+
81
+ - `AcpEmitter`:Backend 调用它,把增量更新交给协议服务端。
82
+ - `AcpPermissionRequester`:Backend 调用它,请 ACP Client 选择批准或拒绝。
83
+
84
+ ### `AcpBackend`
85
+
86
+ `AcpBackend` 是 Python `Protocol`,采用结构化类型检查。实现类不必继承它,只要提供相同字段和
87
+ 方法即可。
88
+
89
+ | 成员 | 责任 |
90
+ | --- | --- |
91
+ | `agent_name` / `agent_version` | 返回给 ACP Client 的 Agent 信息 |
92
+ | `supports_load_session` | 声明是否支持恢复会话 |
93
+ | `create_session()` | 创建应用会话,并返回 ACP 与应用会话的映射 |
94
+ | `load_session()` | 从持久化状态恢复会话 |
95
+ | `prompt()` | 调用真实 Agent,通过回调发送更新和请求审批 |
96
+ | `cancel()` | 取消该会话当前正在执行的任务或 Run |
97
+
98
+ `Protocol` 方法体中的 `...` 只是类型声明,运行时不会创建或调用 `AcpBackend` 实例。真正代码位于
99
+ 各应用的 Backend 实现中。
100
+
101
+ ## 4. `server.py` 的处理流程
102
+
103
+ ### 读取和并发
104
+
105
+ `AcpStdioServer.run()` 从 stdin 逐行读取 JSON。每行必须是一个完整 JSON-RPC 对象。请求会创建独立
106
+ 异步任务,因此不同会话可以并发;`_write_lock` 保证响应写入 stdout 时不会互相穿插。
107
+
108
+ 同一个会话同时只能执行一个 `session/prompt`。服务结束或 stdin 关闭时,未完成任务会被取消并等待
109
+ 收尾。
110
+
111
+ ### 客户端调用 Agent 的方法
112
+
113
+ | 方法 | 行为 |
114
+ | --- | --- |
115
+ | `initialize` | 校验协议版本,返回 Agent 信息与能力声明 |
116
+ | `session/new` | 生成 session ID,调用 `backend.create_session()` |
117
+ | `session/load` | 在 Backend 声明支持时调用 `backend.load_session()` |
118
+ | `session/prompt` | 提取文本内容并调用 `backend.prompt()` |
119
+ | `session/cancel` | 调用 `backend.cancel()`,同时取消服务端 prompt 任务 |
120
+
121
+ ### Agent 主动调用客户端
122
+
123
+ `session/update` 是不需要响应的 JSON-RPC notification。它用于发送文本、思考过程和工具状态。
124
+
125
+ `session/request_permission` 是反向 JSON-RPC request。服务端生成请求 ID,把等待结果的 Future 保存到
126
+ `_pending_client_requests`,然后等待客户端响应。默认等待 300 秒,超时按 `reject_once` 处理。
127
+
128
+ ### 一次提示的完整时序
129
+
130
+ ```text
131
+ Client -> initialize
132
+ Client -> session/new
133
+ Server -> Backend.create_session()
134
+
135
+ Client -> session/prompt
136
+ Server -> Backend.prompt()
137
+ Backend -> emit(AcpUpdate)
138
+ Server -> session/update -> Client
139
+
140
+ Backend -> request_permission() # 可选
141
+ Server -> session/request_permission -> Client
142
+ Client -> 批准或拒绝结果 -> Server
143
+ Server -> 审批结果 -> Backend
144
+
145
+ Backend -> {"stopReason": "end_turn"}
146
+ Server -> session/prompt 最终响应 -> Client
147
+ ```
148
+
149
+ ### 错误映射
150
+
151
+ | 场景 | JSON-RPC code |
152
+ | --- | ---: |
153
+ | JSON 解析失败 | `-32700` |
154
+ | 请求对象或 method 无效 | `-32600` |
155
+ | 参数或协议内容无效 | `-32602` |
156
+ | 方法或能力未实现 | `-32601` |
157
+ | 会话不存在 | `-32004` |
158
+ | Agent 执行异常 | `-32603` |
159
+
160
+ ## 5. 新 Agent 如何接入
161
+
162
+ 应用创建一个实现 `AcpBackend` 形状的类,在 `prompt()` 中调用自己的 Runtime:
163
+
164
+ ```python
165
+ class MyAcpBackend:
166
+ agent_name = "my-agent"
167
+ agent_version = "0.1.0"
168
+ supports_load_session = True
169
+
170
+ async def create_session(self, session_id, cwd, mcp_servers):
171
+ thread_id = await self.runtime.create_thread(session_id)
172
+ return AcpSession(session_id, cwd, {"thread_id": thread_id})
173
+
174
+ async def load_session(self, session_id, cwd, mcp_servers):
175
+ await self.runtime.load_thread(session_id)
176
+ return AcpSession(session_id, cwd, {"thread_id": session_id})
177
+
178
+ async def prompt(self, session, text, emit, request_permission):
179
+ async for chunk in self.runtime.stream(session.metadata["thread_id"], text):
180
+ await emit(AcpUpdate.text(chunk))
181
+ return {"stopReason": "end_turn"}
182
+
183
+ async def cancel(self, session):
184
+ await self.runtime.cancel(session.metadata["thread_id"])
185
+ ```
186
+
187
+ 然后使用:
188
+
189
+ ```python
190
+ await run_acp_stdio(MyAcpBackend(runtime))
191
+ ```
192
+
193
+ 这是示意代码。生产实现还需要接入访问身份、租户、持久化、审批、幂等和安全的 MCP 配置白名单。
194
+
195
+ ## 6. 两个低空 Agent 的当前接入情况
196
+
197
+ ### Vanilla Agent
198
+
199
+ `VanillaAcpBackend` 把 ACP `session_id` 直接映射为 `RuntimeCompat` 的 `thread_id`,创建真实 Run,
200
+ 轮询并转发模型、工具和审批事件。它支持持久化会话恢复;用户作出 ACP 审批决定后,通过恢复命令
201
+ 创建后续 Run。取消操作会调用 Runtime 的 `cancel_run()`。
202
+
203
+ ACP Client 传入的 MCP 配置不会覆盖系统配置的 Gateway。
204
+
205
+ ### LangGraph Agent
206
+
207
+ `LangGraphAcpBackend` 当前把历史保存在 ACP 进程内,调用现有图的 `ainvoke()`,发送一条过程说明和
208
+ 最终回答。它声明 `supports_load_session=False`,因此不承诺进程重启后的 ACP 会话恢复;取消操作通过
209
+ 取消当前图任务完成。
210
+
211
+ 当前 LangGraph Backend 尚未完整转发图内部的工具事件和人工审批事件,能力范围低于 Vanilla Backend。
212
+
213
+ ## 7. 空方法和占位实现审计
214
+
215
+ 截至 2026-09-28,ACP 相关代码没有漏写的具体方法:
216
+
217
+ | 位置 | 表现 | 结论 |
218
+ | --- | --- | --- |
219
+ | `types.py` 的 `AcpBackend` | 4 个方法体使用 `...` | `Protocol` 接口声明,属于正确写法 |
220
+ | `server.py` 的未知方法分支 | 抛出 `NotImplementedError` | 映射为 JSON-RPC `-32601`,不是空实现 |
221
+ | `server.py` 的 `session/load` 检查 | Backend 不支持时抛出 `NotImplementedError` | 按能力声明拒绝请求 |
222
+ | `VanillaAcpBackend` | 没有 `pass`、`...` 或占位方法 | 已实现全部 Backend 方法 |
223
+ | `LangGraphAcpBackend.load_session()` | 明确抛出 `NotImplementedError` | 与 `supports_load_session=False` 一致,属于关闭的可选能力 |
224
+
225
+ 因此不应把 `AcpBackend` 中的 `...` 改成业务实现。Core 只定义端口,具体实现必须继续留在应用 Agent。
226
+
227
+ ## 8. 当前边界
228
+
229
+ - 仅提供 stdio 传输,没有内置 HTTP 或 WebSocket ACP 服务。
230
+ - 当前只接受文本 prompt;初始化能力中图片、音频和嵌入上下文均为 `False`。
231
+ - `mcpServers` 会传给 Backend,但 Core 不负责启动、认证或信任客户端声明的 MCP Server。
232
+ - Core 不负责用户认证、租户授权或工作目录授权,Backend 必须建立可信访问上下文。
233
+ - 服务端 `_sessions` 是当前 ACP 进程的索引;跨进程恢复取决于 Backend 和持久化 Runtime。
234
+ - 未识别的 `AcpUpdate.kind` 会被忽略。
235
+ - Core 支持增量更新,但是否真正逐块输出取决于 Backend 是否持续调用 `emit()`。
236
+ - 工具开始和结束事件必须使用同一个 `toolCallId`,否则客户端无法可靠关联。
@@ -0,0 +1,20 @@
1
+ """Agent Client Protocol(ACP)stdio 适配能力。"""
2
+
3
+ from agent_core.acp.server import (
4
+ AcpProtocolError,
5
+ AcpSessionNotFoundError,
6
+ AcpStdioServer,
7
+ run_acp_stdio,
8
+ )
9
+ from agent_core.acp.types import AcpBackend, AcpSession, AcpUpdate, JsonObject
10
+
11
+ __all__ = [
12
+ "AcpBackend",
13
+ "AcpProtocolError",
14
+ "AcpSession",
15
+ "AcpSessionNotFoundError",
16
+ "AcpStdioServer",
17
+ "AcpUpdate",
18
+ "JsonObject",
19
+ "run_acp_stdio",
20
+ ]
@@ -0,0 +1,351 @@
1
+ """ACP JSON-RPC stdio 服务端。
2
+
3
+ 该实现覆盖通用 Agent Client Protocol 会话流程:初始化、创建/恢复会话、发送提示、
4
+ 流式更新、取消和人工审批。传输层只处理 JSON-RPC,实际执行由 ``AcpBackend`` 注入。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import sys
12
+ import uuid
13
+ from typing import TextIO
14
+
15
+ from agent_core.acp.types import AcpBackend, AcpSession, AcpUpdate, JsonObject
16
+
17
+
18
+ class AcpProtocolError(ValueError):
19
+ """客户端发送了不符合 ACP/JSON-RPC 要求的请求。"""
20
+
21
+
22
+ class AcpSessionNotFoundError(LookupError):
23
+ """请求引用了当前 ACP 服务端不存在的会话。"""
24
+
25
+ def __init__(self, session_id: str) -> None:
26
+ super().__init__(session_id)
27
+ self.session_id = session_id
28
+
29
+
30
+ class AcpStdioServer:
31
+ """基于标准输入输出运行的 ACP 双向 JSON-RPC 服务端。"""
32
+
33
+ protocol_version = 1
34
+
35
+ def __init__(
36
+ self,
37
+ backend: AcpBackend,
38
+ *,
39
+ stdin: TextIO | None = None,
40
+ stdout: TextIO | None = None,
41
+ approval_timeout_seconds: float = 300.0,
42
+ ) -> None:
43
+ if approval_timeout_seconds <= 0:
44
+ raise ValueError("approval_timeout_seconds 必须大于 0")
45
+ self._backend = backend
46
+ self._stdin = stdin or sys.stdin
47
+ self._stdout = stdout or sys.stdout
48
+ self._approval_timeout_seconds = approval_timeout_seconds
49
+ self._sessions: dict[str, AcpSession] = {}
50
+ self._pending_client_requests: dict[str, asyncio.Future[JsonObject]] = {}
51
+ self._prompt_tasks: dict[str, asyncio.Task[None]] = {}
52
+ self._tasks: set[asyncio.Task[None]] = set()
53
+ self._write_lock = asyncio.Lock()
54
+
55
+ async def run(self) -> None:
56
+ """持续处理 ACP Client 写入的 JSON-RPC 行,直到 stdin 关闭。"""
57
+ try:
58
+ while line := await asyncio.to_thread(self._stdin.readline):
59
+ await self._handle_line(line)
60
+ finally:
61
+ # 任务完成回调会从集合移除自身,先创建快照避免遍历期间修改集合。
62
+ for task in tuple(self._tasks):
63
+ task.cancel()
64
+ if self._tasks:
65
+ await asyncio.gather(*tuple(self._tasks), return_exceptions=True)
66
+
67
+ async def _handle_line(self, line: str) -> None:
68
+ try:
69
+ value = json.loads(line)
70
+ except json.JSONDecodeError:
71
+ await self._send_error(None, -32700, "JSON-RPC 解析失败")
72
+ return
73
+ if not isinstance(value, dict):
74
+ await self._send_error(None, -32600, "JSON-RPC 请求必须是对象")
75
+ return
76
+ if "method" not in value:
77
+ self._resolve_client_response(value)
78
+ return
79
+ method = value.get("method")
80
+ if not isinstance(method, str):
81
+ await self._send_error(value.get("id"), -32600, "method 必须是字符串")
82
+ return
83
+ params = value.get("params", {})
84
+ if not isinstance(params, dict):
85
+ await self._send_error(value.get("id"), -32602, "params 必须是对象")
86
+ return
87
+ task = asyncio.create_task(self._handle_request(value.get("id"), method, params))
88
+ self._tasks.add(task)
89
+ task.add_done_callback(self._tasks.discard)
90
+
91
+ def _resolve_client_response(self, value: JsonObject) -> None:
92
+ request_id = value.get("id")
93
+ if not isinstance(request_id, str):
94
+ return
95
+ future = self._pending_client_requests.pop(request_id, None)
96
+ if future is None or future.done():
97
+ return
98
+ error = value.get("error")
99
+ if isinstance(error, dict):
100
+ future.set_exception(AcpProtocolError(str(error.get("message") or "ACP Client 拒绝请求")))
101
+ return
102
+ result = value.get("result", {})
103
+ future.set_result(result if isinstance(result, dict) else {"value": result})
104
+
105
+ async def _handle_request(self, request_id: object, method: str, params: JsonObject) -> None:
106
+ is_request = request_id is not None
107
+ try:
108
+ result = await self._dispatch(method, params)
109
+ except asyncio.CancelledError:
110
+ if is_request:
111
+ await self._send_result(request_id, {"stopReason": "cancelled"})
112
+ except AcpProtocolError as exc:
113
+ if is_request:
114
+ await self._send_error(request_id, -32602, str(exc))
115
+ except AcpSessionNotFoundError as exc:
116
+ if is_request:
117
+ await self._send_error(request_id, -32004, f"会话不存在:{exc.session_id}")
118
+ except NotImplementedError as exc:
119
+ if is_request:
120
+ await self._send_error(request_id, -32601, str(exc))
121
+ except Exception as exc:
122
+ if is_request:
123
+ await self._send_error(request_id, -32603, f"ACP Agent 执行失败:{exc}")
124
+ else:
125
+ if is_request:
126
+ await self._send_result(request_id, result)
127
+
128
+ async def _dispatch(self, method: str, params: JsonObject) -> JsonObject:
129
+ if method == "initialize":
130
+ return self._initialize(params)
131
+ if method == "session/new":
132
+ return await self._new_session(params)
133
+ if method == "session/load":
134
+ if not getattr(self._backend, "supports_load_session", False):
135
+ raise NotImplementedError("当前 Agent 不支持 ACP 会话恢复")
136
+ return await self._load_session(params)
137
+ if method == "session/prompt":
138
+ return await self._prompt(params)
139
+ if method == "session/cancel":
140
+ return await self._cancel(params)
141
+ raise NotImplementedError(f"不支持的 ACP 方法:{method}")
142
+
143
+ def _initialize(self, params: JsonObject) -> JsonObject:
144
+ client_version = params.get("protocolVersion", self.protocol_version)
145
+ if not isinstance(client_version, int):
146
+ raise AcpProtocolError("protocolVersion 必须是整数")
147
+ if client_version != self.protocol_version:
148
+ raise AcpProtocolError(f"不支持的 ACP 版本:{client_version},当前仅支持 {self.protocol_version}")
149
+ return {
150
+ "protocolVersion": self.protocol_version,
151
+ "agentCapabilities": {
152
+ "loadSession": bool(getattr(self._backend, "supports_load_session", False)),
153
+ "promptCapabilities": {
154
+ "image": False,
155
+ "audio": False,
156
+ "embeddedContext": False,
157
+ },
158
+ "mcpCapabilities": {"http": False, "sse": False},
159
+ },
160
+ "agentInfo": {
161
+ "name": self._backend.agent_name,
162
+ "title": self._backend.agent_name,
163
+ "version": self._backend.agent_version,
164
+ },
165
+ }
166
+
167
+ async def _new_session(self, params: JsonObject) -> JsonObject:
168
+ cwd = self._require_string(params, "cwd")
169
+ session_id = str(uuid.uuid4())
170
+ session = await self._backend.create_session(session_id, cwd, self._mcp_servers(params))
171
+ self._sessions[session.session_id] = session
172
+ return {"sessionId": session.session_id, "modes": [], "configOptions": []}
173
+
174
+ async def _load_session(self, params: JsonObject) -> JsonObject:
175
+ session_id = self._require_string(params, "sessionId")
176
+ cwd = self._require_string(params, "cwd")
177
+ session = await self._backend.load_session(session_id, cwd, self._mcp_servers(params))
178
+ self._sessions[session.session_id] = session
179
+ return {"sessionId": session.session_id, "modes": [], "configOptions": []}
180
+
181
+ async def _prompt(self, params: JsonObject) -> JsonObject:
182
+ session_id = self._require_string(params, "sessionId")
183
+ session = self._session(session_id)
184
+ if session_id in self._prompt_tasks:
185
+ raise AcpProtocolError("该 ACP 会话已有正在执行的提示")
186
+ text = self._prompt_text(params.get("prompt"))
187
+ task = asyncio.current_task()
188
+ if task is None:
189
+ raise RuntimeError("无法获取 ACP 提示任务")
190
+ self._prompt_tasks[session_id] = task
191
+ try:
192
+ return await self._backend.prompt(
193
+ session,
194
+ text,
195
+ lambda update: self._emit_update(session_id, update),
196
+ lambda request: self._request_permission(session_id, request),
197
+ )
198
+ finally:
199
+ self._prompt_tasks.pop(session_id, None)
200
+
201
+ async def _cancel(self, params: JsonObject) -> JsonObject:
202
+ session_id = self._require_string(params, "sessionId")
203
+ session = self._session(session_id)
204
+ await self._backend.cancel(session)
205
+ task = self._prompt_tasks.get(session_id)
206
+ if task is not None and not task.done():
207
+ task.cancel()
208
+ return {}
209
+
210
+ async def _emit_update(self, session_id: str, update: AcpUpdate) -> None:
211
+ payload = self._update_payload(update)
212
+ if payload is None:
213
+ return
214
+ await self._send_notification("session/update", {"sessionId": session_id, "update": payload})
215
+
216
+ async def _request_permission(self, session_id: str, request: JsonObject) -> str:
217
+ options = request.get("options")
218
+ if not isinstance(options, list) or not options:
219
+ options = [
220
+ {"optionId": "approve_once", "name": "批准", "kind": "allow_once"},
221
+ {"optionId": "reject_once", "name": "拒绝", "kind": "reject_once"},
222
+ ]
223
+ allowed_option_ids = {
224
+ option["optionId"]
225
+ for option in options
226
+ if isinstance(option, dict) and isinstance(option.get("optionId"), str) and option["optionId"]
227
+ }
228
+ if not allowed_option_ids:
229
+ raise AcpProtocolError("审批选项必须包含非空 optionId")
230
+ result = await self._request_client(
231
+ "session/request_permission",
232
+ {
233
+ "sessionId": session_id,
234
+ "toolCall": request.get("toolCall", {}),
235
+ "options": options,
236
+ },
237
+ )
238
+ for key in ("selectedOptionId", "optionId", "outcome"):
239
+ value = result.get(key)
240
+ if isinstance(value, str) and value:
241
+ return value if value in allowed_option_ids else "reject_once"
242
+ return "reject_once"
243
+
244
+ async def _request_client(self, method: str, params: JsonObject) -> JsonObject:
245
+ request_id = f"agent-{uuid.uuid4()}"
246
+ future: asyncio.Future[JsonObject] = asyncio.get_running_loop().create_future()
247
+ self._pending_client_requests[request_id] = future
248
+ await self._write({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
249
+ try:
250
+ return await asyncio.wait_for(future, timeout=self._approval_timeout_seconds)
251
+ except TimeoutError:
252
+ return {"selectedOptionId": "reject_once"}
253
+ finally:
254
+ self._pending_client_requests.pop(request_id, None)
255
+
256
+ @staticmethod
257
+ def _update_payload(update: AcpUpdate) -> JsonObject | None:
258
+ text = update.payload.get("text")
259
+ if update.kind == "text" and isinstance(text, str):
260
+ return {
261
+ "sessionUpdate": "agent_message_chunk",
262
+ "content": {"type": "text", "text": text},
263
+ }
264
+ if update.kind == "thought" and isinstance(text, str):
265
+ return {
266
+ "sessionUpdate": "agent_thought_chunk",
267
+ "content": {"type": "text", "text": text},
268
+ }
269
+ if update.kind == "tool_started":
270
+ return {
271
+ "sessionUpdate": "tool_call",
272
+ "toolCallId": str(update.payload.get("toolCallId") or uuid.uuid4()),
273
+ "title": str(update.payload.get("title") or "调用工具"),
274
+ "kind": "other",
275
+ "status": "in_progress",
276
+ "rawInput": update.payload.get("input", {}),
277
+ }
278
+ if update.kind == "tool_finished":
279
+ return {
280
+ "sessionUpdate": "tool_call_update",
281
+ "toolCallId": str(update.payload.get("toolCallId") or "tool"),
282
+ "status": "completed" if update.payload.get("success", True) else "failed",
283
+ "content": update.payload.get("content", []),
284
+ }
285
+ return None
286
+
287
+ @staticmethod
288
+ def _prompt_text(prompt: object) -> str:
289
+ if not isinstance(prompt, list):
290
+ raise AcpProtocolError("prompt 必须是内容块数组")
291
+ texts: list[str] = []
292
+ for block in prompt:
293
+ if not isinstance(block, dict):
294
+ continue
295
+ if block.get("type") != "text":
296
+ continue
297
+ text = block.get("text")
298
+ if isinstance(text, str) and text.strip():
299
+ texts.append(text.strip())
300
+ value = "\n".join(texts).strip()
301
+ if not value:
302
+ raise AcpProtocolError("prompt 中缺少文本内容")
303
+ return value
304
+
305
+ @staticmethod
306
+ def _mcp_servers(params: JsonObject) -> list[JsonObject]:
307
+ value = params.get("mcpServers", [])
308
+ if not isinstance(value, list):
309
+ raise AcpProtocolError("mcpServers 必须是数组")
310
+ return [item for item in value if isinstance(item, dict)]
311
+
312
+ @staticmethod
313
+ def _require_string(params: JsonObject, key: str) -> str:
314
+ value = params.get(key)
315
+ if not isinstance(value, str) or not value.strip():
316
+ raise AcpProtocolError(f"{key} 必须是非空字符串")
317
+ return value.strip()
318
+
319
+ def _session(self, session_id: str) -> AcpSession:
320
+ session = self._sessions.get(session_id)
321
+ if session is None:
322
+ raise AcpSessionNotFoundError(session_id)
323
+ return session
324
+
325
+ async def _send_result(self, request_id: object, result: JsonObject) -> None:
326
+ await self._write({"jsonrpc": "2.0", "id": request_id, "result": result})
327
+
328
+ async def _send_error(self, request_id: object, code: int, message: str) -> None:
329
+ await self._write({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}})
330
+
331
+ async def _send_notification(self, method: str, params: JsonObject) -> None:
332
+ await self._write({"jsonrpc": "2.0", "method": method, "params": params})
333
+
334
+ async def _write(self, payload: JsonObject) -> None:
335
+ line = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
336
+ async with self._write_lock:
337
+ await asyncio.to_thread(self._stdout.write, line)
338
+ await asyncio.to_thread(self._stdout.flush)
339
+
340
+
341
+ async def run_acp_stdio(backend: AcpBackend) -> None:
342
+ """使用默认标准输入输出启动 ACP 服务。"""
343
+ await AcpStdioServer(backend).run()
344
+
345
+
346
+ __all__ = [
347
+ "AcpProtocolError",
348
+ "AcpSessionNotFoundError",
349
+ "AcpStdioServer",
350
+ "run_acp_stdio",
351
+ ]
@@ -0,0 +1,86 @@
1
+ """ACP 适配器的公共协议。
2
+
3
+ ACP(Agent Client Protocol)使用 JSON-RPC 双向通信。这里定义的接口不依赖
4
+ 某个 Agent 框架,业务项目只需把自己的会话和运行时接到 ``AcpBackend``。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Awaitable, Callable
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Protocol
12
+
13
+ JsonObject = dict[str, Any]
14
+
15
+
16
+ @dataclass
17
+ class AcpSession:
18
+ """ACP 会话与业务运行时会话之间的映射。"""
19
+
20
+ session_id: str
21
+ cwd: str
22
+ metadata: JsonObject = field(default_factory=dict)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AcpUpdate:
27
+ """业务运行时向 ACP Client 推送的一条标准化更新。"""
28
+
29
+ kind: str
30
+ payload: JsonObject = field(default_factory=dict)
31
+
32
+ @classmethod
33
+ def text(cls, text: str) -> AcpUpdate:
34
+ """构造可显示给用户的 Agent 文本片段。"""
35
+ return cls("text", {"text": text})
36
+
37
+ @classmethod
38
+ def thought(cls, text: str) -> AcpUpdate:
39
+ """构造可选展示的运行过程说明。"""
40
+ return cls("thought", {"text": text})
41
+
42
+
43
+ AcpEmitter = Callable[[AcpUpdate], Awaitable[None]]
44
+ AcpPermissionRequester = Callable[[JsonObject], Awaitable[str]]
45
+
46
+
47
+ class AcpBackend(Protocol):
48
+ """将具体 Agent Runtime 适配为 ACP 会话服务。"""
49
+
50
+ agent_name: str
51
+ agent_version: str
52
+ supports_load_session: bool
53
+
54
+ async def create_session(
55
+ self,
56
+ session_id: str,
57
+ cwd: str,
58
+ mcp_servers: list[JsonObject],
59
+ ) -> AcpSession: ...
60
+
61
+ async def load_session(
62
+ self,
63
+ session_id: str,
64
+ cwd: str,
65
+ mcp_servers: list[JsonObject],
66
+ ) -> AcpSession: ...
67
+
68
+ async def prompt(
69
+ self,
70
+ session: AcpSession,
71
+ text: str,
72
+ emit: AcpEmitter,
73
+ request_permission: AcpPermissionRequester,
74
+ ) -> JsonObject: ...
75
+
76
+ async def cancel(self, session: AcpSession) -> None: ...
77
+
78
+
79
+ __all__ = [
80
+ "AcpBackend",
81
+ "AcpEmitter",
82
+ "AcpPermissionRequester",
83
+ "AcpSession",
84
+ "AcpUpdate",
85
+ "JsonObject",
86
+ ]