fp-acp 0.1.1.dev0__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.
fp_acp/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ fp-acp — ACP (Agent Client Protocol) Server
3
+
4
+ 将五块卵石作为 ACP Server 接入 VS Code / Zed 等 IDE,
5
+ 让编辑器直接驱动 AI Agent,无需复制粘贴。
6
+
7
+ 协议: JSON-RPC 2.0 over stdio
8
+ 参考: https://github.com/agentclientprotocol/agent-client-protocol
9
+ """
10
+
11
+ from .server import main as run
12
+
13
+ __all__ = ["run"]
fp_acp/main.py ADDED
File without changes
fp_acp/server.py ADDED
@@ -0,0 +1,1077 @@
1
+ """
2
+ ACP Server — Agent Client Protocol 实现 (v1 兼容)
3
+ ==================================================
4
+
5
+ 将 Five Pebbles 作为 ACP Server 接入 IDE(Zed / VS Code / JetBrains 等)。
6
+
7
+ 协议规范:https://agentclientprotocol.com/protocol/v1
8
+ 参考实现:https://github.com/agentclientprotocol/agent-client-protocol
9
+
10
+ 架构:
11
+ IDE (ACP Client)
12
+ │ stdio (JSON-RPC 2.0, 一行一条 JSON)
13
+
14
+ ACP Server(本文件)
15
+ │ redirect_stdout → stderr(日志/display)
16
+
17
+ Agent.process() ← 五块卵石核心
18
+
19
+ 关键修正 (v1):
20
+ - protocolVersion: 1 (integer, 不是字符串)
21
+ - 字段名使用 camelCase: agentCapabilities, agentInfo, sessionId
22
+ - session/prompt 使用 prompt 数组 + 向后兼容 messages
23
+ - 通知使用 ACP v1 的 update 结构
24
+ """
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import json
29
+ import os
30
+ import re
31
+ import sys
32
+ import traceback
33
+ from typing import Any
34
+
35
+ from fp_core.core.io import IOChannel
36
+ from fp_core.core.lifecycle import LifecycleHook
37
+
38
+ # ═══════════════════════════════════════════════════════════
39
+ # ACP v1 常量
40
+ # ═══════════════════════════════════════════════════════════
41
+ ACP_PROTOCOL_VERSION = 1
42
+
43
+
44
+ # ═══════════════════════════════════════════════════════════
45
+ # ACPIO — ACP 专用的 IO 通道
46
+ # ═══════════════════════════════════════════════════════════
47
+
48
+
49
+ class ACPIO(IOChannel):
50
+ """
51
+ ACP IO 通道 — 带流式推送的缓冲输出。
52
+
53
+ 核心设计:
54
+ - 所有 info/item/error/hint/say 调用累积到缓冲区
55
+ - 每累积约 300 字符或显式调用 partial_flush() 时,
56
+ 通过 send_chunk 回调推送一条 agent_message_chunk
57
+ - process() 结束后调用 flush_text() 获取剩余文本
58
+ - 每次 ask() 先 flush 缓冲区,然后返回 "q" 取消交互
59
+
60
+ 为何需要流式?
61
+ 长时间运行的 Agent 任务(如代码生成、多步工具调用)中,
62
+ 用户需要看到渐进式反馈,而不是静默等待后突然弹出完整回复。
63
+ """
64
+
65
+ _STREAM_THRESHOLD = 300
66
+
67
+ def __init__(self, send_chunk=None):
68
+ """
69
+ Args:
70
+ send_chunk: 可选的回调函数,接收 (text: str),
71
+ 在缓冲达到阈值时自动推送 agent_message_chunk。
72
+ 同步调用,直接写入 stdout。
73
+ """
74
+ self._buffer: list[str] = []
75
+ self._send_chunk = send_chunk
76
+ self._char_count = 0
77
+
78
+ def _accumulate(self, text: str):
79
+ """写入缓冲区,超过阈值时自动推送流式块"""
80
+ self._buffer.append(str(text))
81
+ self._char_count += len(str(text))
82
+ if self._send_chunk and self._char_count >= self._STREAM_THRESHOLD:
83
+ self._partial_flush()
84
+
85
+ def _partial_flush(self):
86
+ """推送当前缓冲区作为一条 agent_message_chunk,不清除全部"""
87
+ if not self._buffer or not self._send_chunk:
88
+ return
89
+ text = "\n".join(self._buffer)
90
+ # 标记:非最终块,由 send_chunk 决定如何处理
91
+ self._send_chunk(text)
92
+ self._buffer.clear()
93
+ self._char_count = 0
94
+
95
+ def flush_text(self) -> str:
96
+ """返回缓冲区剩余文本并清空(process 结束后调用)"""
97
+ if not self._buffer:
98
+ return ""
99
+ merged = "\n".join(self._buffer)
100
+ self._buffer.clear()
101
+ self._char_count = 0
102
+ return merged
103
+
104
+ async def ask(self, prompt: str) -> str:
105
+ """ACP 模式下无法交互,返回 'q' 取消"""
106
+ return "q"
107
+
108
+ def say(self, text: str):
109
+ self._accumulate(text)
110
+
111
+ def info(self, text: str):
112
+ self._accumulate(text)
113
+
114
+ def hint(self, text: str):
115
+ self._accumulate(text)
116
+
117
+ def error(self, text: str):
118
+ self._accumulate(text)
119
+
120
+ def item(self, text: str):
121
+ self._accumulate(text)
122
+
123
+
124
+ class ACPServer:
125
+ """
126
+ ACP (Agent Client Protocol) v1 服务器
127
+
128
+ 通过 stdin/stdout 与 IDE 通信,每条消息为一行 JSON(newline-delimited JSON)。
129
+
130
+ 实现的方法:
131
+ initialize 协议握手,协商版本和能力
132
+ session/new 创建新对话会话
133
+ session/load 恢复已有会话
134
+ session/prompt 发送用户消息并获取 AI 回复(核心交互)
135
+ session/set_mode 切换 Agent 模式
136
+
137
+ 实现的通知:
138
+ session/cancel 取消当前操作
139
+ initialzed 客户端已就绪
140
+
141
+ 发出的通知:
142
+ session/update 推送计划/工具调用/回复
143
+ Follow Agent: 文件操作时通知 IDE 打开对应文件
144
+ """
145
+
146
+ def __init__(self, agent_instance=None):
147
+ """
148
+ Args:
149
+ agent_instance: 可选,注入已存在的 Agent 实例。
150
+ 为 None 时自动创建新实例。
151
+ """
152
+ os.environ.setdefault("FP_SUBAGENT_QUIET", "1")
153
+
154
+ # ── 保存真正的 stdout 引用(JSON-RPC 通道) ──
155
+ self._stdout = sys.stdout
156
+
157
+ # ── 创建 Agent(print/display 重定向到 stderr) ──
158
+ if agent_instance is not None:
159
+ self._agent = agent_instance
160
+ else:
161
+ from fp_core.core.agent import Agent
162
+
163
+ with contextlib.redirect_stdout(sys.stderr):
164
+ self._agent = Agent(enable_log=False)
165
+
166
+ self._session_id: str | None = None
167
+
168
+ # ── 工具调用追踪(配对 call ↔ result 通知) ──
169
+ self._tool_call_counter = 0
170
+ self._active_tool_call_ids: dict[str, str] = {}
171
+
172
+ # ── 工具参数缓存(call → result 阶段共享) ──
173
+ self._last_edit_args: dict | None = None # edit_file 参数,用于构建 diff
174
+ self._last_read_path: str | None = None # read_file 路径,用于 result 的 resource URI
175
+
176
+ # ── "Follow Agent" 跟踪注册 ──
177
+ self._register_follow_hooks()
178
+
179
+ # ═══════════════════════════════════════════════════════
180
+ # "Follow Agent" — 让 IDE 跟踪 Agent 文件操作
181
+ # ═══════════════════════════════════════════════════════
182
+
183
+ # bash 命令中的文件路径匹配模式
184
+ _FILE_PATH_PATTERN = re.compile(
185
+ r"(?:cat|less|more|head|tail|vim|nano|code|xdg-open|less|grep|rg|sed|awk)\s+"
186
+ r'([^\s;|&`\'"()]+)'
187
+ )
188
+
189
+ def _register_follow_hooks(self):
190
+ """注册生命周期钩子,在工具调用时通知 IDE 关注文件"""
191
+
192
+ mgr = self._agent.lifecycle
193
+
194
+ # ── 工具调用前:提取文件路径,通知 IDE 打开 ──
195
+ mgr.register(
196
+ LifecycleHook.ON_TOOL_CALL,
197
+ self._on_tool_call,
198
+ name="acp_follow_tool_call",
199
+ priority=1000, # 高优先级
200
+ )
201
+
202
+ # ── 工具完成后:通知 IDE 工具已完成 ──
203
+ mgr.register(
204
+ LifecycleHook.ON_TOOL_RESULT,
205
+ self._on_tool_result,
206
+ name="acp_follow_tool_result",
207
+ priority=1000,
208
+ )
209
+
210
+ def _extract_file_path(self, tool_name: str, args: dict) -> str | None:
211
+ """
212
+ 从工具调用参数中提取文件路径。
213
+
214
+ 支持的工具:
215
+ read_file/write_file/edit_file → file_path 参数
216
+ file_fingerprint/elf_analysis → file_path 参数
217
+ bash → 从命令中猜测文件路径
218
+
219
+ 参数 args 应为已解析为 dict 的参数字典,由调用方保证。
220
+ """
221
+ if not isinstance(args, dict):
222
+ return None
223
+
224
+ # 直接有 file_path 参数的
225
+ fp = args.get("file_path")
226
+ if fp and isinstance(fp, str) and os.path.isfile(fp):
227
+ return os.path.abspath(fp)
228
+
229
+ # bash 命令中尝试提取文件路径
230
+ if tool_name == "bash":
231
+ cmd = args.get("command", "")
232
+ if cmd:
233
+ match = self._FILE_PATH_PATTERN.search(cmd)
234
+ if match:
235
+ potential = os.path.abspath(os.path.expanduser(match.group(1)))
236
+ if os.path.isfile(potential):
237
+ return potential
238
+
239
+ return None
240
+
241
+ def _get_tool_kind(self, tool_name: str) -> str:
242
+ """返回工具类型分类,供 IDE 显示"""
243
+ if tool_name in ("read_file",):
244
+ return "read"
245
+ elif tool_name in ("write_file", "edit_file"):
246
+ return "edit"
247
+ elif tool_name in ("bash",):
248
+ return "bash"
249
+ elif tool_name in ("file_fingerprint", "elf_analysis"):
250
+ return "analyze"
251
+ else:
252
+ return "other"
253
+
254
+ async def _on_tool_call(self, context, tool_name="", tool_args="", **kwargs):
255
+ """工具调用前 — 发送带 input 详情的 tool_call 通知
256
+
257
+ ACP 规范支持 input 字段显示工具原始参数。
258
+ bash 命令显示完整命令,edit_file 显示 old→new 摘要,read_file 显示文件路径。
259
+ """
260
+ # ── 会话未就绪时跳过(start() 之前触发的钩子) ──
261
+ if self._session_id is None:
262
+ return
263
+
264
+ # ── 解析参数 ──
265
+ args_dict = {}
266
+ with contextlib.suppress(json.JSONDecodeError, TypeError):
267
+ args_dict = json.loads(tool_args) if isinstance(tool_args, str) else {}
268
+
269
+ # ── 缓存 edit_file 参数供 _on_tool_result 构建 diff ──
270
+ if tool_name == "edit_file":
271
+ self._last_edit_args = args_dict
272
+
273
+ # ── 缓存 read_file 路径供 _on_tool_result 构建 resource URI ──
274
+ if tool_name == "read_file":
275
+ fp = args_dict.get("file_path", "")
276
+ self._last_read_path = fp if isinstance(fp, str) else None
277
+
278
+ # ── 构建通知 ──
279
+ kind = self._get_tool_kind(tool_name)
280
+ file_path = self._extract_file_path(tool_name, args_dict)
281
+ title = self._build_tool_title(tool_name, args_dict, kind)
282
+ input_text = self._build_tool_input(tool_name, args_dict)
283
+
284
+ notification: dict = {
285
+ "sessionId": self._session_id,
286
+ "update": {
287
+ "sessionUpdate": "tool_call",
288
+ "toolCallId": self._tool_call_id(tool_name),
289
+ "title": title,
290
+ "kind": kind,
291
+ "status": "in_progress",
292
+ },
293
+ }
294
+
295
+ # 有 input 就加上
296
+ if input_text:
297
+ notification["update"]["input"] = {"type": "text", "text": input_text}
298
+
299
+ # 有文件路径就加上 content (resource)
300
+ if file_path and os.path.isfile(file_path):
301
+ notification["update"]["content"] = [
302
+ {
303
+ "type": "resource",
304
+ "resource": {
305
+ "uri": f"file://{file_path}",
306
+ "mimeType": self._guess_mime(file_path),
307
+ },
308
+ }
309
+ ]
310
+
311
+ self._send_notification("session/update", notification)
312
+
313
+ # ── 工具标题和 input 构建 ────────────────────────────
314
+
315
+ # Emoji 分类,让 IDE 的 tool_call 面板一目了然
316
+ _TOOL_EMOJI: dict[str, str] = {
317
+ "read": "📖",
318
+ "edit": "✏️",
319
+ "bash": "🖥️",
320
+ "analyze": "🔍",
321
+ "other": "🔧",
322
+ }
323
+
324
+ def _build_tool_title(self, tool_name: str, args: dict, kind: str) -> str:
325
+ """构建一目了然的工具标题,含 emoji 和关键参数"""
326
+ emoji = self._TOOL_EMOJI.get(kind, "🔧")
327
+
328
+ if tool_name == "bash":
329
+ cmd = args.get("command", "")
330
+ first_line = cmd.split("\n")[0] if cmd else ""
331
+ if len(first_line) > 100:
332
+ first_line = first_line[:100] + "…"
333
+ return f"{emoji} {first_line}" if first_line else f"{emoji} bash"
334
+ elif tool_name == "read_file":
335
+ fp = args.get("file_path", "?")
336
+ return f"{emoji} {fp}"
337
+ elif tool_name == "write_file":
338
+ fp = args.get("file_path", "?")
339
+ return f"{emoji} 写入 {os.path.basename(fp)}"
340
+ elif tool_name == "edit_file":
341
+ fp = args.get("file_path", "?")
342
+ return f"{emoji} 编辑 {os.path.basename(fp)}"
343
+ elif tool_name == "python":
344
+ code = args.get("code", "")
345
+ first_line = code.split("\n")[0][:70] if code else ""
346
+ return f"{emoji} 🐍 {first_line}" if first_line else f"{emoji} 🐍 python"
347
+ elif tool_name == "web_search":
348
+ q = args.get("query", "?")
349
+ return f"{emoji} 搜索: {q[:60]}{'…' if len(q) > 60 else ''}"
350
+ elif tool_name == "subagent":
351
+ task = args.get("task", "")
352
+ summary = task[:60] + "…" if len(task) > 60 else task
353
+ return f"{emoji} 子任务: {summary}"
354
+ elif tool_name == "web_fetch":
355
+ url = args.get("url", "?")
356
+ return f"{emoji} 抓取: {url[:60]}"
357
+ elif tool_name == "file_fingerprint":
358
+ fp = args.get("file_path", "?")
359
+ return f"{emoji} 指纹: {os.path.basename(fp)}"
360
+ elif tool_name == "elf_analysis":
361
+ fp = args.get("file_path", "?")
362
+ return f"{emoji} ELF: {os.path.basename(fp)}"
363
+ return f"{emoji} {tool_name}"
364
+
365
+ def _build_tool_input(self, tool_name: str, args: dict) -> str | None:
366
+ """构建工具 input 文本(显示在 Zed 的 tool_call 详情中)"""
367
+ if tool_name == "bash":
368
+ return args.get("command", "")
369
+ elif tool_name == "read_file":
370
+ fp = args.get("file_path", "")
371
+ off = args.get("offset")
372
+ lim = args.get("limit")
373
+ parts = [fp]
374
+ if off is not None:
375
+ parts.append(f"offset={off}")
376
+ if lim is not None:
377
+ parts.append(f"limit={lim}")
378
+ return " ".join(parts)
379
+ elif tool_name == "edit_file":
380
+ old = args.get("old_string", "")
381
+ new = args.get("new_string", "")
382
+ old_short = old[:60] + "..." if len(old) > 60 else old
383
+ new_short = new[:60] + "..." if len(new) > 60 else new
384
+ return f"{args.get('file_path', '?')}\n- {old_short}\n+ {new_short}"
385
+ elif tool_name == "write_file":
386
+ return args.get("file_path", "")
387
+ elif tool_name == "python":
388
+ return args.get("code", "")[:200]
389
+ elif tool_name == "web_search":
390
+ return args.get("query", "")
391
+ elif tool_name == "subagent":
392
+ task = args.get("task", "")
393
+ ctx = args.get("context", "")
394
+ if ctx:
395
+ return f"{task}\n[context] {ctx[:100]}"
396
+ return task
397
+ return None
398
+
399
+ # ── 结果摘要 ────────────────────────────────────────
400
+
401
+ @staticmethod
402
+ def _summarize_result(tool_name: str, result_str: str) -> str:
403
+ """
404
+ 按工具类型对结果分级截断,返回适合展示的摘要文本。
405
+
406
+ 策略:
407
+ read_file → 全文(用户就是想看文件内容)
408
+ edit_file → 仅 diff(不发完整文本,由 diff 结构承载)
409
+ write_file → 500 字(确认写入了什么)
410
+ bash → 2000 字(可能包含错误栈)
411
+ python → 2000 字(代码计算结果)
412
+ web_search → 800 字(搜索结果摘要)
413
+ subagent → 1500 字(子任务结论)
414
+ 其他 → 300 字
415
+ """
416
+ limits: dict[str, int] = {
417
+ "read_file": 0, # 0 = 不限
418
+ "edit_file": 0, # 纯 diff,不发 output 文本
419
+ "write_file": 500,
420
+ "bash": 2000,
421
+ "python": 2000,
422
+ "web_search": 800,
423
+ "subagent": 1500,
424
+ }
425
+ limit = limits.get(tool_name, 300)
426
+
427
+ if limit == 0 or len(result_str) <= limit:
428
+ return result_str
429
+
430
+ truncated = result_str[:limit]
431
+ truncated += f"\n\n**...(已截断,共 {len(result_str)} 字符)**"
432
+ return truncated
433
+
434
+ async def _on_tool_result(self, context, tool_name="", result="", **kwargs):
435
+ """工具完成后 — 发送 tool_call_update,包含 output 和可能的 diff"""
436
+ # ── 会话未就绪时跳过 ──
437
+ if self._session_id is None:
438
+ return
439
+
440
+ status = "failed" if "❌" in str(result)[:10] else "completed"
441
+ result_str = str(result)
442
+
443
+ notification: dict = {
444
+ "sessionId": self._session_id,
445
+ "update": {
446
+ "sessionUpdate": "tool_call_update",
447
+ "toolCallId": self._tool_call_id_for_result(tool_name),
448
+ "status": status,
449
+ },
450
+ }
451
+
452
+ # ── output:所有工具都发结果摘要(分级截断) ──
453
+ if result_str and status != "failed":
454
+ summarized = self._summarize_result(tool_name, result_str)
455
+ if summarized:
456
+ if tool_name == "read_file":
457
+ # read_file:用 resource 类型发,让 IDE 渲染为代码块
458
+ read_path = self._last_read_path or "(inline)"
459
+ notification["update"]["output"] = {
460
+ "type": "resource",
461
+ "resource": {
462
+ "uri": f"file://{read_path}",
463
+ "mimeType": self._guess_mime(read_path) if read_path != "(inline)" else "text/plain",
464
+ "text": summarized,
465
+ },
466
+ }
467
+ elif tool_name == "edit_file":
468
+ # edit_file:有 diff 块时不重复发 output 文本
469
+ has_diff = (
470
+ status == "completed"
471
+ and self._last_edit_args
472
+ and self._last_edit_args.get("old_string")
473
+ and self._last_edit_args.get("new_string")
474
+ )
475
+ if not has_diff:
476
+ notification["update"]["output"] = {
477
+ "type": "text",
478
+ "text": summarized,
479
+ }
480
+ else:
481
+ notification["update"]["output"] = {
482
+ "type": "text",
483
+ "text": summarized,
484
+ }
485
+
486
+ # ── diff:edit_file 构建 diff 内容块 ──
487
+ if tool_name == "edit_file" and status == "completed" and self._last_edit_args:
488
+ args = self._last_edit_args
489
+ old_text = args.get("old_string", "")
490
+ new_text = args.get("new_string", "")
491
+ file_path = args.get("file_path", "")
492
+ if old_text and new_text and file_path:
493
+ notification["update"]["content"] = [
494
+ {
495
+ "type": "diff",
496
+ "path": file_path,
497
+ "oldText": old_text,
498
+ "newText": new_text,
499
+ }
500
+ ]
501
+
502
+ # ── 错误时显示错误信息 ──
503
+ if status == "failed":
504
+ notification["update"]["output"] = {
505
+ "type": "text",
506
+ "text": self._summarize_result(tool_name, result_str),
507
+ }
508
+
509
+ self._send_notification("session/update", notification)
510
+
511
+ def _tool_call_id(self, tool_name: str) -> str:
512
+ """生成唯一的 toolCallId,并记录到活跃表供 result 阶段回查"""
513
+ self._tool_call_counter += 1
514
+ call_id = f"fp_{tool_name}_{self._tool_call_counter}"
515
+ self._active_tool_call_ids[tool_name] = call_id
516
+ return call_id
517
+
518
+ def _tool_call_id_for_result(self, tool_name: str) -> str:
519
+ """取最近一次同名 tool_call 的 ID,用于配对 tool_call_update"""
520
+ return self._active_tool_call_ids.get(tool_name, f"fp_{tool_name}_unknown")
521
+
522
+ @staticmethod
523
+ def _guess_mime(file_path: str) -> str:
524
+ """根据文件扩展名猜测 MIME 类型"""
525
+ ext = os.path.splitext(file_path)[1].lower()
526
+ mime_map = {
527
+ ".py": "text/x-python",
528
+ ".js": "text/javascript",
529
+ ".ts": "text/typescript",
530
+ ".jsx": "text/jsx",
531
+ ".tsx": "text/tsx",
532
+ ".json": "application/json",
533
+ ".md": "text/markdown",
534
+ ".html": "text/html",
535
+ ".css": "text/css",
536
+ ".c": "text/x-c",
537
+ ".cpp": "text/x-c++",
538
+ ".h": "text/x-c",
539
+ ".hpp": "text/x-c++",
540
+ ".rs": "text/rust",
541
+ ".go": "text/x-go",
542
+ ".java": "text/x-java",
543
+ ".yaml": "text/yaml",
544
+ ".yml": "text/yaml",
545
+ ".toml": "text/toml",
546
+ ".sh": "text/x-shellscript",
547
+ ".txt": "text/plain",
548
+ ".csv": "text/csv",
549
+ ".xml": "text/xml",
550
+ ".sql": "text/x-sql",
551
+ ".rb": "text/x-ruby",
552
+ ".php": "text/x-php",
553
+ }
554
+ return mime_map.get(ext, "text/plain")
555
+
556
+ # ═══════════════════════════════════════════════════════
557
+ # 公共接口
558
+ # ═══════════════════════════════════════════════════════
559
+
560
+ async def start(self):
561
+ """启动 ACP 服务器,从 stdin 读取 JSON-RPC 消息"""
562
+ await self._agent.ensure_initialized()
563
+ self._session_id = self._agent.session.session_id
564
+
565
+ self._log(f"✅ ACP Server 启动 (session={self._session_id})")
566
+ self._log(f" 模型: {self._agent.model}")
567
+
568
+ # ── 设置异步 stdin 读取器 ──
569
+ loop = asyncio.get_event_loop()
570
+ reader = asyncio.StreamReader()
571
+ protocol = asyncio.StreamReaderProtocol(reader)
572
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
573
+
574
+ try:
575
+ await self._read_loop(reader)
576
+ except asyncio.CancelledError:
577
+ self._log("收到取消信号,正在关闭...")
578
+ except Exception as e:
579
+ self._log(f"意外错误: {e}")
580
+ traceback.print_exc(file=sys.stderr)
581
+ finally:
582
+ await self._shutdown_agent()
583
+
584
+ # ═══════════════════════════════════════════════════════
585
+ # 消息循环
586
+ # ═══════════════════════════════════════════════════════
587
+
588
+ async def _read_loop(self, reader: asyncio.StreamReader):
589
+ """从 stdin 逐行读取 JSON-RPC 消息"""
590
+ while True:
591
+ line = await reader.readline()
592
+ if not line:
593
+ self._log("stdin 已关闭 (EOF),优雅退出")
594
+ break
595
+
596
+ text = line.decode("utf-8").strip()
597
+ if not text:
598
+ continue
599
+
600
+ try:
601
+ request: dict = json.loads(text)
602
+ except json.JSONDecodeError as e:
603
+ self._log(f"⚠️ JSON 解析失败: {e}")
604
+ continue
605
+
606
+ await self._dispatch(request)
607
+
608
+ async def _dispatch(self, request: dict):
609
+ """分发 JSON-RPC 请求到对应的处理器"""
610
+ method = request.get("method", "")
611
+ req_id = request.get("id")
612
+ params = request.get("params", {})
613
+
614
+ # ── 通知(无 id)的处理 ──
615
+ if req_id is None:
616
+ await self._handle_notification(method, params)
617
+ return
618
+
619
+ # ── 请求(有 id)的处理 ──
620
+ handler = self._get_handler(method)
621
+ if handler is None:
622
+ self._send_error(req_id, -32601, f"Method not found: {method}")
623
+ return
624
+
625
+ try:
626
+ with contextlib.redirect_stdout(sys.stderr):
627
+ result = await handler(params)
628
+
629
+ self._send_result(req_id, result)
630
+
631
+ # session/new 和 session/load 之后,等待 IDE 完全消化响应并注册 sessionId,
632
+ # 然后再发送 available_commands_update 通知。直接连续发送会导致 Zed
633
+ # 在处理通知时 session 尚未注册,报 "unknown session" 并丢弃命令列表。
634
+ if method in ("session/new", "session/load"):
635
+ await asyncio.sleep(0.05)
636
+ self._send_available_commands()
637
+ except asyncio.CancelledError:
638
+ self._send_result(req_id, None)
639
+ except Exception as e:
640
+ self._log(f"❌ 处理 {method} 失败: {e}")
641
+ traceback.print_exc(file=sys.stderr)
642
+ self._send_error(req_id, -32603, str(e))
643
+
644
+ def _get_handler(self, method: str):
645
+ """根据方法名查找对应的处理器"""
646
+ handlers = {
647
+ "initialize": self._handle_initialize,
648
+ "session/new": self._handle_session_new,
649
+ "session/load": self._handle_session_load,
650
+ "session/prompt": self._handle_session_prompt,
651
+ "session/list": self._handle_session_list,
652
+ "session/commands": self._handle_session_commands,
653
+ "session/set_mode": self._handle_session_set_mode,
654
+ }
655
+ return handlers.get(method)
656
+
657
+ async def _handle_notification(self, method: str, params: dict):
658
+ """处理通知(无 id 的请求)"""
659
+ if method == "session/cancel":
660
+ self._log("收到取消通知")
661
+ self._agent.cancel()
662
+ elif method == "initialized":
663
+ self._log("客户端已初始化")
664
+ # 不在此处注册命令——此时只有默认 sessionId(IDE 不认识它),
665
+ # 命令由 session/new 和 session/load handler 在正确的会话中注册。
666
+ else:
667
+ pass
668
+
669
+ # ═══════════════════════════════════════════════════════
670
+ # ACP v1 方法处理器
671
+ # ═══════════════════════════════════════════════════════
672
+
673
+ async def _handle_initialize(self, params: dict) -> dict:
674
+ """
675
+ ACP v1 协议握手。
676
+
677
+ 参考: https://agentclientprotocol.com/protocol/v1/initialization
678
+ """
679
+ client_info = params.get("clientInfo", {})
680
+ self._log(f"客户端连接: {client_info.get('name', '?')} v{client_info.get('version', '?')}")
681
+
682
+ return {
683
+ "protocolVersion": ACP_PROTOCOL_VERSION,
684
+ "agentCapabilities": {
685
+ "loadSession": True,
686
+ "sessionCapabilities": {
687
+ "list": {},
688
+ },
689
+ "promptCapabilities": {
690
+ "image": False,
691
+ "audio": False,
692
+ "embeddedContext": True,
693
+ },
694
+ "mcpCapabilities": {
695
+ "http": False,
696
+ "sse": False,
697
+ },
698
+ },
699
+ "agentInfo": {
700
+ "name": "five-pebbles",
701
+ "title": "Five Pebbles",
702
+ "version": "0.1.0",
703
+ },
704
+ "authMethods": [],
705
+ }
706
+
707
+ async def _handle_session_new(self, params: dict) -> dict:
708
+ """
709
+ ACP v1 创建新会话。
710
+
711
+ 创建后立即通过 notification 注册所有斜杠命令,
712
+ 否则 Zed 会拦截所有未注册的 / 命令。
713
+
714
+ 参考: https://agentclientprotocol.com/protocol/v1/session-setup
715
+ """
716
+ self._agent.save_context()
717
+ new_sid = self._agent.session.create_session()
718
+ self._agent.rebuild_context()
719
+ self._session_id = new_sid
720
+ self._log(f"创建新会话: {new_sid}")
721
+
722
+ # 注意:命令注册通知在 _dispatch 中响应之后发送,
723
+ # 以确保 IDE 先拿到 sessionId 再处理命令列表。
724
+ return {"sessionId": new_sid}
725
+
726
+ async def _handle_session_load(self, params: dict) -> dict:
727
+ """
728
+ ACP v1 恢复已有会话。
729
+
730
+ 参考: https://agentclientprotocol.com/protocol/v1/session-setup#loading-sessions
731
+ """
732
+ # ACP v1 使用 camelCase
733
+ session_id = params.get("sessionId") or params.get("session_id", "")
734
+ if not session_id:
735
+ return {"sessionId": self._agent.session.session_id}
736
+
737
+ self._agent.save_context()
738
+ if self._agent.switch_session(session_id):
739
+ self._session_id = session_id
740
+ self._log(f"恢复会话: {session_id}")
741
+
742
+ # 注意:命令注册通知在 _dispatch 中响应之后发送
743
+ return {"sessionId": session_id}
744
+ else:
745
+ self._log(f"会话不存在: {session_id}")
746
+ raise ValueError(f"Session not found: {session_id}")
747
+
748
+ async def _handle_session_list(self, params: dict) -> dict:
749
+ """
750
+ ACP v1 列出所有历史会话。
751
+
752
+ 参考: https://agentclientprotocol.com/protocol/v1/session-list
753
+ """
754
+ all_sessions = self._agent.session.list_sessions()
755
+ current_sid = self._agent.session.session_id
756
+
757
+ sessions_list = []
758
+ for sid, meta in sorted(
759
+ all_sessions.items(),
760
+ key=lambda x: x[1].get("updated", x[1].get("created", "")),
761
+ reverse=True,
762
+ ):
763
+ title = meta.get("summary", "") or ""
764
+ msg_count = meta.get("message_count", 0)
765
+
766
+ session_entry = {
767
+ "sessionId": sid,
768
+ "title": title[:80] if title else None,
769
+ "updatedAt": meta.get("updated", meta.get("created", "")),
770
+ "_meta": {
771
+ "messageCount": msg_count,
772
+ "isCurrent": sid == current_sid,
773
+ },
774
+ }
775
+ sessions_list.append(session_entry)
776
+
777
+ return {"sessions": sessions_list}
778
+
779
+ async def _handle_session_prompt(self, params: dict) -> dict:
780
+ """
781
+ ACP v1 核心交互:发送用户消息并返回 AI 回复。
782
+
783
+ ACP v1 规范要求:
784
+ - 回复内容通过 session/update (agent_message_chunk) 通知发送
785
+ - 响应 result 只包含 stopReason
786
+
787
+ 支持两种请求格式:
788
+ 1. ACP v1 标准: prompt 数组 (content blocks)
789
+ 2. 兼容格式: messages 数组 (OpenAI-style)
790
+
791
+ 参考: https://agentclientprotocol.com/protocol/v1/prompt-turn
792
+ """
793
+ # ── 提取用户消息 ──
794
+ user_prompt = self._extract_prompt(params)
795
+
796
+ if user_prompt is None:
797
+ raise ValueError("No prompt provided. Use 'prompt' (ACP format) or 'messages' (OpenAI format)")
798
+
799
+ # ── 发送 plan 通知 ──
800
+ self._send_plan_notification()
801
+
802
+ # ── 调用 Agent 核心 ──
803
+ # 使用 ACPIO 缓冲输出,并在累积到阈值时自动推送 agent_message_chunk,
804
+ # 让用户在长时间任务中看到渐进式反馈。
805
+ #
806
+ # 工具调用通知由 __init__ 中注册的永久钩子
807
+ # (acp_follow_tool_call / acp_follow_tool_result) 自动发送。
808
+ acp_io = ACPIO(send_chunk=self._send_message_notification)
809
+
810
+ self._log(f"发送给 Agent: {user_prompt[:120]}")
811
+ response = await self._agent.process(user_prompt, io=acp_io)
812
+
813
+ # ── 处理剩余文本(流式推送后缓冲区可能已空) ──
814
+ buf = acp_io.flush_text()
815
+ reply_text = response.content or ""
816
+
817
+ if buf.strip():
818
+ # 有缓冲区残留(最终块)
819
+ self._send_message_notification(buf)
820
+
821
+ if reply_text and reply_text not in buf:
822
+ # response.content 有新内容且未在流式推送中出现过
823
+ self._send_message_notification(reply_text)
824
+
825
+ # ── ACP v1 响应只包含 stopReason ──
826
+ return {
827
+ "stopReason": "end_turn",
828
+ }
829
+
830
+ async def _handle_session_set_mode(self, params: dict) -> dict:
831
+ """切换 Agent 模式(预留)"""
832
+ mode = params.get("mode", "normal")
833
+ return {"mode": mode}
834
+
835
+ async def _handle_session_commands(self, params: dict) -> dict:
836
+ """
837
+ 返回可用斜杠命令列表(作为 session/commands 请求的 fallback)。
838
+
839
+ 标准 ACP v1 通过 available_commands_update 通知注册命令,
840
+ 此 handler 作为补充,覆盖 IDE 通过请求方式查询命令的场景。
841
+
842
+ 命令名不带 '/' 前缀(ACP v1 约定),IDE 端会自行添加。
843
+ """
844
+ try:
845
+ from fp_core.commands import get_all_commands
846
+
847
+ cmds = get_all_commands()
848
+ # ACP v1 协议中命令名不带 '/' 前缀
849
+ commands = [{"name": name, "description": desc} for name, desc in sorted(cmds.items())]
850
+ return {"commands": commands}
851
+ except Exception as e:
852
+ self._log(f"获取命令列表失败: {e}")
853
+ return {"commands": []}
854
+
855
+ # ═══════════════════════════════════════════════════════
856
+ # 辅助方法
857
+ # ═══════════════════════════════════════════════════════
858
+
859
+ def _extract_prompt(self, params: dict) -> str | None:
860
+ """
861
+ 从 ACP v1 请求中提取用户消息文本。
862
+
863
+ 优先级:
864
+ 1. params.prompt (ACP v1 标准 content blocks)
865
+ 2. params.messages (OpenAI 兼容格式)
866
+
867
+ ACP v1 标准 prompt 示例:
868
+ [
869
+ {"type": "text", "text": "Hello"},
870
+ {"type": "resource", "resource": {"uri": "file:///...", "text": "..."}}
871
+ ]
872
+
873
+ OpenAI 兼容 messages 示例:
874
+ [
875
+ {"role": "user", "content": "Hello"}
876
+ ]
877
+ """
878
+ # ── 方式1: ACP v1 标准 prompt ──
879
+ prompt_list = params.get("prompt")
880
+ if prompt_list is not None and isinstance(prompt_list, list) and len(prompt_list) > 0:
881
+ return self._prompt_blocks_to_text(prompt_list)
882
+
883
+ # ── 方式2: OpenAI 兼容 messages ──
884
+ messages = params.get("messages")
885
+ if messages is not None and isinstance(messages, list) and len(messages) > 0:
886
+ last_msg = messages[-1]
887
+ if last_msg.get("role") in ("user", "assistant"):
888
+ return self._build_messages_text(messages)
889
+
890
+ return None
891
+
892
+ def _prompt_blocks_to_text(self, blocks: list[dict]) -> str:
893
+ """
894
+ 将 ACP v1 的 content blocks 转换为文本。
895
+
896
+ 支持:
897
+ - text block: {"type": "text", "text": "..."}
898
+ - resource block: {"type": "resource", "resource": {"uri": "...", "text": "...", ...}}
899
+
900
+ 组合规则:
901
+ - 所有 text blocks 按顺序拼接
902
+ - resource blocks 转换为 [文件: uri]\n内容 格式
903
+ """
904
+ parts = []
905
+ for block in blocks:
906
+ block_type = block.get("type", "")
907
+
908
+ if block_type == "text":
909
+ text = block.get("text", "")
910
+ if text:
911
+ parts.append(text)
912
+
913
+ elif block_type == "resource":
914
+ resource = block.get("resource", {})
915
+ uri = resource.get("uri", "")
916
+ text = resource.get("text", "") or resource.get("content", "")
917
+ mime = resource.get("mimeType", "")
918
+
919
+ header = f"[文件: {uri}]"
920
+ if mime:
921
+ header += f" ({mime})"
922
+ parts.append(f"{header}\n{text}")
923
+
924
+ return "\n".join(parts)
925
+
926
+ def _build_messages_text(self, messages: list[dict]) -> str:
927
+ """
928
+ 从 OpenAI 格式的 messages 中提取最后一条 user 消息。
929
+
930
+ Agent 自己管理对话历史(self._conv.messages),
931
+ 不需要外部拼接历史轮次。只提取最新用户输入。
932
+ """
933
+ for msg in reversed(messages):
934
+ if msg.get("role") == "user":
935
+ content = msg.get("content", "")
936
+ return content if isinstance(content, str) else str(content)
937
+ return ""
938
+
939
+ def _send_available_commands(self):
940
+ """注册所有斜杠命令到 Zed(通过 available_commands_update 通知)
941
+
942
+ Zed 的聊天面板维护一个白名单,只有注册过的 / 命令才会发给 Agent,
943
+ 未注册的直接在 UI 报错"is not a recognized command"。
944
+
945
+ 命令名不含 '/' 前缀(ACP v1 规范),IDE 端在匹配用户输入时会自行添加。
946
+ 官方规范参考: https://agentclientprotocol.com/protocol/slash-commands
947
+
948
+ 调用时机:由 _dispatch 在 session/new 和 session/load 的响应之后发送,
949
+ 确保 IDE 先拿到 sessionId 再处理命令列表。
950
+ """
951
+ try:
952
+ from fp_core.commands import get_all_commands
953
+
954
+ cmds = get_all_commands()
955
+ # ACP v1 规范: name 字段不包含 '/' 前缀
956
+ # 示例: {"name": "help", "description": "显示此帮助"}
957
+ available = [{"name": name, "description": desc} for name, desc in sorted(cmds.items())]
958
+
959
+ if available:
960
+ self._send_notification(
961
+ "session/update",
962
+ {
963
+ "sessionId": self._session_id,
964
+ "update": {
965
+ "sessionUpdate": "available_commands_update",
966
+ "availableCommands": available,
967
+ },
968
+ },
969
+ )
970
+ self._log(f"已注册 {len(available)} 个斜杠命令")
971
+ except Exception as e:
972
+ self._log(f"注册斜杠命令失败: {e}")
973
+
974
+ def _send_plan_notification(self):
975
+ """发送 ACP v1 plan 更新通知"""
976
+ self._send_notification(
977
+ "session/update",
978
+ {
979
+ "sessionId": self._session_id,
980
+ "update": {
981
+ "sessionUpdate": "plan",
982
+ "entries": [
983
+ {
984
+ "content": "五块卵石正在思考...",
985
+ "priority": "high",
986
+ "status": "running",
987
+ },
988
+ ],
989
+ },
990
+ },
991
+ )
992
+
993
+ def _send_message_notification(self, text: str):
994
+ """通过 agent_message_chunk 通知发送回复内容"""
995
+ import uuid
996
+
997
+ self._send_notification(
998
+ "session/update",
999
+ {
1000
+ "sessionId": self._session_id,
1001
+ "update": {
1002
+ "sessionUpdate": "agent_message_chunk",
1003
+ "messageId": f"msg_{uuid.uuid4().hex[:12]}",
1004
+ "content": {
1005
+ "type": "text",
1006
+ "text": text,
1007
+ },
1008
+ },
1009
+ },
1010
+ )
1011
+
1012
+ async def _shutdown_agent(self):
1013
+ """安全关闭 Agent"""
1014
+ try:
1015
+ self._agent.set_nuclear_exit()
1016
+ with contextlib.redirect_stdout(sys.stderr):
1017
+ await self._agent.shutdown()
1018
+ except Exception as e:
1019
+ self._log(f"关闭 Agent 时出错: {e}")
1020
+
1021
+ # ═══════════════════════════════════════════════════════
1022
+ # JSON-RPC 通信(写真正的 stdout)
1023
+ # ═══════════════════════════════════════════════════════
1024
+
1025
+ def _send_result(self, req_id: Any, result: dict | None):
1026
+ """发送 JSON-RPC 成功响应"""
1027
+ msg = json.dumps(
1028
+ {"jsonrpc": "2.0", "id": req_id, "result": result},
1029
+ ensure_ascii=False,
1030
+ )
1031
+ self._stdout.write(msg + "\n")
1032
+ self._stdout.flush()
1033
+
1034
+ def _send_error(self, req_id: Any, code: int, message: str):
1035
+ """发送 JSON-RPC 错误响应"""
1036
+ msg = json.dumps(
1037
+ {
1038
+ "jsonrpc": "2.0",
1039
+ "id": req_id,
1040
+ "error": {"code": code, "message": message},
1041
+ },
1042
+ ensure_ascii=False,
1043
+ )
1044
+ self._stdout.write(msg + "\n")
1045
+ self._stdout.flush()
1046
+
1047
+ def _send_notification(self, method: str, params: dict):
1048
+ """发送 JSON-RPC 通知(无 id)"""
1049
+ msg = json.dumps(
1050
+ {"jsonrpc": "2.0", "method": method, "params": params},
1051
+ ensure_ascii=False,
1052
+ )
1053
+ self._stdout.write(msg + "\n")
1054
+ self._stdout.flush()
1055
+
1056
+ @staticmethod
1057
+ def _log(msg: str):
1058
+ """日志输出到 stderr"""
1059
+ print(f"[ACP] {msg}", file=sys.stderr, flush=True)
1060
+
1061
+
1062
+ # ═══════════════════════════════════════════════════════════
1063
+ # 入口
1064
+ # ═══════════════════════════════════════════════════════════
1065
+
1066
+
1067
+ def main():
1068
+ """启动 ACP Server 的入口函数"""
1069
+ server = ACPServer()
1070
+ try:
1071
+ asyncio.run(server.start())
1072
+ except KeyboardInterrupt:
1073
+ print("[ACP] 用户中断", file=sys.stderr, flush=True)
1074
+
1075
+
1076
+ if __name__ == "__main__":
1077
+ main()
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: fp-acp
3
+ Version: 0.1.1.dev0
4
+ Summary: 五块卵石 - ACP Server (JSON-RPC 2.0)
5
+ Author: zpb
6
+ License-Expression: MIT
7
+ Keywords: agent,acp,ide
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Communications
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: fp-core>=0.1.0
17
+
18
+ # fp-acp — 五块卵石 ACP 通信协议
19
+
20
+ [![PyPI](https://img.shields.io/pypi/v/fp-acp)](https://pypi.org/project/fp-acp/)
21
+ [![Python](https://img.shields.io/pypi/pyversions/fp-acp)](https://pypi.org/project/fp-acp/)
22
+ [![License](https://img.shields.io/pypi/l/fp-acp)](LICENSE)
23
+
24
+ ## 简介
25
+
26
+ **fp-acp** 实现了五块卵石(Five Pebbles)的 Agent Communication Protocol(ACP),一个基于 **JSON-RPC 2.0** 的通信协议。它允许外部进程(IDE 插件、其他 Agent、自动化脚本)通过标准化的 API 与 Agent 交互。
27
+
28
+ > 通过 `pip install fp-agent[acp]` 或 `pip install fp-acp` 安装。
29
+
30
+ ---
31
+
32
+ ## 什么是 ACP?
33
+
34
+ ACP(Agent Communication Protocol)定义了 Agent 与外部世界之间的标准通信契约。它使得:
35
+
36
+ - **IDE 集成** — VS Code、Neovim、Emacs 等编辑器通过 ACP 调用 Agent 能力
37
+ - **跨 Agent 通信** — 多个 Agent 实例通过 ACP 协作完成任务
38
+ - **自动化管道** — CI/CD 脚本通过 ACP 集成 Agent 进行代码审查、文档生成等
39
+
40
+ ```
41
+ ┌──────────┐ JSON-RPC 2.0 ┌───────────┐
42
+ │ IDE │ ◄────────────────► │ ACP │
43
+ │ 插件/ │ │ Server │──► fp-core Agent
44
+ │ 脚本 │ │ (fp-acp) │
45
+ └──────────┘ └───────────┘
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 安装
51
+
52
+ ```bash
53
+ pip install fp-acp
54
+ ```
55
+
56
+ 要求 Python >= 3.11。
57
+
58
+ ---
59
+
60
+ ## 快速使用
61
+
62
+ ### 默认端口
63
+
64
+ ```bash
65
+ fp-acp
66
+ ```
67
+
68
+ ### 指定主机和端口
69
+
70
+ ```bash
71
+ fp-acp --host 127.0.0.1 --port 9090
72
+ ```
73
+
74
+ 启动日志:
75
+
76
+ ```
77
+ ╭─ Five Pebbles ACP Server ────────────────╮
78
+ │ │
79
+ │ 监听地址: tcp://127.0.0.1:9090 │
80
+ │ 协议: JSON-RPC 2.0 │
81
+ │ │
82
+ │ 按 Ctrl+C 停止服务 │
83
+ ╰───────────────────────────────────────────╯
84
+ ```
85
+
86
+ ---
87
+
88
+ ## JSON-RPC 接口
89
+
90
+ ACP 使用标准的 JSON-RPC 2.0 协议,支持以下方法:
91
+
92
+ ### 核心方法
93
+
94
+ | 方法 | 参数 | 返回 | 说明 |
95
+ |------|------|------|------|
96
+ | `chat` | `{message, session_id?, stream?}` | `{response}` | 发送消息并获取回复 |
97
+ | `chat_stream` | `{message, session_id?}` | SSE 流 | 流式聊天(Server-Sent Events) |
98
+ | `tools/list` | `{}` | `[{name, description, parameters}]` | 获取可用工具列表 |
99
+ | `tools/call` | `{name, arguments}` | `{result}` | 直接调用工具 |
100
+ | `sessions/list` | `{}` | `[{id, created_at, message_count}]` | 列出会话 |
101
+ | `sessions/create` | `{id?}` | `{id}` | 创建新会话 |
102
+ | `sessions/delete` | `{id}` | `{success}` | 删除会话 |
103
+ | `sessions/history` | `{id, limit?}` | `[{role, content}]` | 获取会话历史 |
104
+ | `config/get` | `{}` | `{config}` | 获取当前配置 |
105
+ | `config/set` | `{key, value}` | `{success}` | 更新配置项 |
106
+ | `ping` | `{}` | `{pong}` | 健康检查 |
107
+ | `shutdown` | `{}` | `{success}` | 优雅关闭 Server |
108
+
109
+ ### 调用示例
110
+
111
+ #### 使用 curl
112
+
113
+ ```bash
114
+ # 发送聊天消息
115
+ curl -X POST http://127.0.0.1:9090 \
116
+ -H "Content-Type: application/json" \
117
+ -d '{
118
+ "jsonrpc": "2.0",
119
+ "id": 1,
120
+ "method": "chat",
121
+ "params": {
122
+ "message": "你好,请介绍一下自己",
123
+ "session_id": "my-session"
124
+ }
125
+ }'
126
+ ```
127
+
128
+ 响应:
129
+
130
+ ```json
131
+ {
132
+ "jsonrpc": "2.0",
133
+ "id": 1,
134
+ "result": {
135
+ "response": "你好!我是五块卵石,一个基于生命周期钩子的插件化 Agent。"
136
+ }
137
+ }
138
+ ```
139
+
140
+ #### 使用 Python
141
+
142
+ ```python
143
+ import httpx
144
+
145
+ rpc_request = {
146
+ "jsonrpc": "2.0",
147
+ "id": 1,
148
+ "method": "tools/list",
149
+ "params": {}
150
+ }
151
+
152
+ response = httpx.post("http://127.0.0.1:9090", json=rpc_request)
153
+ tools = response.json()["result"]
154
+ print(tools)
155
+ ```
156
+
157
+ ---
158
+
159
+ ## 使用场景
160
+
161
+ ### 1. VS Code 集成
162
+
163
+ 通过 ACP,VS Code 扩展可以直接调用 Agent 进行代码审查、重构建议、自动补全:
164
+
165
+ ```
166
+ 用户选中代码 → 右键 "Ask Five Pebbles" → ACP Server 返回分析结果
167
+ ```
168
+
169
+ ### 2. 多 Agent 协作
170
+
171
+ ```python
172
+ # Agent A 通过 ACP 调用 Agent B 的专业能力
173
+ response = httpx.post("http://agent-b:9090", json={
174
+ "jsonrpc": "2.0",
175
+ "method": "chat",
176
+ "params": {"message": "请分析这段代码的安全性: ..."}
177
+ })
178
+ ```
179
+
180
+ ### 3. CI/CD 集成
181
+
182
+ ```yaml
183
+ # .github/workflows/code-review.yml
184
+ jobs:
185
+ review:
186
+ steps:
187
+ - run: |
188
+ curl -X POST http://agent:9090 \
189
+ -H "Content-Type: application/json" \
190
+ -d '{
191
+ "method": "chat",
192
+ "params": {
193
+ "message": "Review the diff in commit ${{ github.sha }}"
194
+ }
195
+ }'
196
+ ```
197
+
198
+ ---
199
+
200
+ ## 安全说明
201
+
202
+ - ACP Server 默认绑定 `127.0.0.1`(仅本地访问)
203
+ - 如果需要远程访问,使用 `--host 0.0.0.0` 并配合防火墙或反向代理
204
+ - 生产环境建议添加 TLS 加密和 API 认证
205
+
206
+ ---
207
+
208
+ ## 依赖
209
+
210
+ | 依赖 | 版本 | 用途 |
211
+ |------|------|------|
212
+ | `fp-core` | >= 0.1.0 | Agent 核心引擎 |
213
+
214
+ ---
215
+
216
+ ## 许可
217
+
218
+ MIT © zpb
@@ -0,0 +1,8 @@
1
+ fp_acp/__init__.py,sha256=3m8eSSAUZQIAFZjDv_3lioJoGP184Gs-xrXCPJWRb5g,332
2
+ fp_acp/main.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ fp_acp/server.py,sha256=76b4P1PygjqRvT-fZvBiPLBiMX4gzjxLdps8QxYdCbA,42399
4
+ fp_acp-0.1.1.dev0.dist-info/METADATA,sha256=zDc1aJtHfmiGF7-gZIfvc8MiIdh2D5QLLK4DmQfYkvE,5741
5
+ fp_acp-0.1.1.dev0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ fp_acp-0.1.1.dev0.dist-info/entry_points.txt,sha256=PI-a8VhVDRfUvcsjKba4SweGhfBK4druk4dbaQecgoI,46
7
+ fp_acp-0.1.1.dev0.dist-info/top_level.txt,sha256=UsbtVmlAAieXVO1Ko5yz2SFWcGw8QxGwB97AtCPOLCM,7
8
+ fp_acp-0.1.1.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fp-acp = fp_acp.server:main
@@ -0,0 +1 @@
1
+ fp_acp