traceforge-pcb 1.0.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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
traceforge/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Traceforge: an AI agent that designs, edits and reviews KiCad circuit boards."""
2
+
3
+ __version__ = "1.0.0"
traceforge/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from traceforge.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,570 @@
1
+ """Agent Client Protocol server: ``traceforge acp``.
2
+
3
+ Lets an ACP client (Zed, Neovim plugins, ...) use traceforge as its agent over stdio. Each ACP
4
+ session wraps a :class:`traceforge.chat.ChatSession`, so slash commands, modes, models, threads
5
+ and the approval policy behave as they do in the terminal; this module only translates:
6
+
7
+ * agent events to ``session/update`` notifications (message and thought chunks, tool calls and
8
+ their results, the plan from ``write_todos``, token usage);
9
+ * approval requests to ``session/request_permission`` (allow once, allow always, reject);
10
+ * ACP session modes to traceforge modes (ask, plan, agent), and config options to effort, model
11
+ and approvals;
12
+ * ACP prompts (text, images, files the client attaches) to a traceforge prompt.
13
+
14
+ stdout carries the protocol, so nothing here prints. Needs ``pip install agent-client-protocol``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import unquote, urlparse
25
+
26
+ import acp
27
+ from acp import schema
28
+
29
+ from traceforge import __version__
30
+ from traceforge.agent import AgentEvent
31
+ from traceforge.approval import ApprovalAnswer, ApprovalRequest
32
+ from traceforge.attachments import AttachmentError, ImageAttachment
33
+ from traceforge.chat import APPROVALS, EFFORTS, MODE_COPY, MODES, ChatSession
34
+ from traceforge.config import Settings
35
+ from traceforge.sessions import SessionStore, prompt_text
36
+
37
+ ALLOW_ONCE, ALLOW_ALWAYS, REJECT = "allow_once", "allow_always", "reject_once"
38
+ _TOOL_KINDS = {"read": "read", "write": "edit", "shell": "execute", "interactive": "other"}
39
+ _SEARCH_TOOLS = {"grep", "search_files", "glob", "find_parts", "search_parts", "web_search"}
40
+ _STOP = {"end_turn": "end_turn", "max_tokens": "max_tokens", "refusal": "refusal"}
41
+ _MODE_NAMES = {"ask": "Ask", "plan": "Plan", "agent": "Agent"}
42
+ _TEXT_LIMIT = 6000
43
+
44
+
45
+ def tool_kind(name: str, kind: str) -> str:
46
+ if name in _SEARCH_TOOLS:
47
+ return "search"
48
+ return _TOOL_KINDS.get(str(kind), "other")
49
+
50
+
51
+ def _locations(arguments: dict[str, Any], workspace: Path) -> list[schema.ToolCallLocation]:
52
+ path = arguments.get("path") if isinstance(arguments, dict) else None
53
+ if not isinstance(path, str) or not path:
54
+ return []
55
+ target = Path(path)
56
+ return [
57
+ schema.ToolCallLocation(path=str(target if target.is_absolute() else workspace / target))
58
+ ]
59
+
60
+
61
+ def file_path(uri: str) -> str:
62
+ parsed = urlparse(uri)
63
+ return unquote(parsed.path) if parsed.scheme == "file" else uri
64
+
65
+
66
+ def prompt_to_text(blocks: list[Any]) -> tuple[str, list[ImageAttachment], list[str]]:
67
+ """The text, image attachments and problems of an ACP prompt's content blocks."""
68
+ parts: list[str] = []
69
+ images: list[ImageAttachment] = []
70
+ problems: list[str] = []
71
+ for block in blocks:
72
+ kind = getattr(block, "type", "")
73
+ if kind == "text":
74
+ parts.append(block.text)
75
+ elif kind == "image":
76
+ try:
77
+ if block.data:
78
+ images.append(ImageAttachment.from_base64(block.data, "image"))
79
+ elif block.uri and file_path(block.uri).startswith("/"):
80
+ images.append(ImageAttachment.from_path(Path(file_path(block.uri))))
81
+ except AttachmentError as exc:
82
+ problems.append(str(exc))
83
+ elif kind == "resource_link":
84
+ parts.append(f"@{file_path(block.uri)}")
85
+ elif kind == "resource":
86
+ resource = block.resource
87
+ body = getattr(resource, "text", None)
88
+ if body is not None:
89
+ parts.append(
90
+ f"<attached_file path={file_path(resource.uri)!r}>\n{body}\n</attached_file>"
91
+ )
92
+ return "\n".join(p for p in parts if p).strip(), images, problems
93
+
94
+
95
+ @dataclass
96
+ class AcpSession:
97
+ id: str
98
+ chat: ChatSession
99
+ cancelled: bool = False
100
+ calls: dict[str, str] = field(default_factory=dict) # tool name -> id of its latest call
101
+ pending: set[asyncio.Future] = field(default_factory=set)
102
+
103
+
104
+ class AcpUI:
105
+ """:class:`traceforge.chat.ChatUI` that talks to an ACP client."""
106
+
107
+ def __init__(self, agent: TraceforgeAcpAgent):
108
+ self.agent = agent
109
+ self.session: AcpSession | None = None
110
+
111
+ @property
112
+ def conn(self) -> Any:
113
+ return self.agent.conn
114
+
115
+ async def _update(self, update: Any) -> None:
116
+ assert self.session is not None
117
+ await self.conn.session_update(session_id=self.session.id, update=update)
118
+
119
+ async def on_event(self, event: AgentEvent) -> None:
120
+ session = self.session
121
+ assert session is not None
122
+ kind, data = event.kind, event.data
123
+ if kind == "text":
124
+ await self._update(acp.update_agent_message_text(str(data)))
125
+ elif kind == "thinking":
126
+ await self._update(acp.update_agent_thought_text(str(data)))
127
+ elif kind == "tool_call":
128
+ arguments = data.get("input") or {}
129
+ name = str(data.get("name"))
130
+ session.calls[name] = str(data.get("id"))
131
+ kind_name = self.agent.kind_of(session, name)
132
+ await self._update(
133
+ acp.start_tool_call(
134
+ str(data.get("id")),
135
+ f"{name} {_brief(arguments)}".strip(),
136
+ kind=kind_name,
137
+ status="in_progress",
138
+ locations=_locations(arguments, session.chat.workspace),
139
+ raw_input=arguments,
140
+ )
141
+ )
142
+ elif kind == "tool_result":
143
+ content = str(data.get("content") or "")
144
+ await self._update(
145
+ acp.update_tool_call(
146
+ str(data.get("id")),
147
+ status="failed" if data.get("is_error") else "completed",
148
+ content=[acp.tool_content(acp.text_block(content[:_TEXT_LIMIT]))]
149
+ if content
150
+ else None,
151
+ raw_output=content[:_TEXT_LIMIT],
152
+ )
153
+ )
154
+ elif kind == "todos":
155
+ entries = [
156
+ acp.plan_entry(
157
+ str(t.get("content") or t.get("task") or ""),
158
+ status=t.get("status")
159
+ if t.get("status") in {"pending", "in_progress", "completed"}
160
+ else "pending",
161
+ )
162
+ for t in data or []
163
+ if isinstance(t, dict)
164
+ ]
165
+ await self._update(acp.update_plan(entries))
166
+ elif kind == "usage" and isinstance(data, dict):
167
+ used = int(data.get("input_tokens", 0)) + int(data.get("cache_read_input_tokens", 0))
168
+ from traceforge.models import context_window
169
+
170
+ await self._update(
171
+ schema.UsageUpdate(used=used, size=context_window(), session_update="usage_update")
172
+ )
173
+ elif kind in {"status", "notice"}:
174
+ message = data.get("message") if isinstance(data, dict) else data
175
+ await self._update(acp.update_agent_thought_text(f"{message}\n"))
176
+ elif kind == "error":
177
+ await self._update(acp.update_agent_message_text(f"\n**Error:** {data}\n"))
178
+
179
+ async def approve(self, request: ApprovalRequest) -> bool | ApprovalAnswer:
180
+ session = self.session
181
+ assert session is not None
182
+ call_id = session.calls.get(request.tool, request.tool)
183
+ options = [
184
+ schema.PermissionOption(option_id=ALLOW_ONCE, name="Allow", kind="allow_once"),
185
+ schema.PermissionOption(
186
+ option_id=ALLOW_ALWAYS, name=f"Always allow {request.tool}", kind="allow_always"
187
+ ),
188
+ schema.PermissionOption(option_id=REJECT, name="Reject", kind="reject_once"),
189
+ ]
190
+ tool_call = schema.ToolCallUpdate(
191
+ tool_call_id=call_id,
192
+ title=request.summary or f"{request.tool} {_brief(request.arguments)}",
193
+ kind=tool_kind(request.tool, request.kind),
194
+ status="pending",
195
+ raw_input=request.arguments,
196
+ )
197
+ outcome = await self._ask_client(tool_call, options)
198
+ if outcome == ALLOW_ONCE:
199
+ return ApprovalAnswer(True, "once")
200
+ if outcome == ALLOW_ALWAYS:
201
+ return ApprovalAnswer(True, "tool")
202
+ return ApprovalAnswer(False, "once", "the user rejected it in the editor")
203
+
204
+ async def _ask_client(self, tool_call: Any, options: list[Any]) -> str | None:
205
+ """The chosen option id, or None when the client cancelled."""
206
+ session = self.session
207
+ assert session is not None
208
+ future = asyncio.ensure_future(
209
+ self.conn.request_permission(
210
+ session_id=session.id, tool_call=tool_call, options=options
211
+ )
212
+ )
213
+ session.pending.add(future)
214
+ try:
215
+ response = await future
216
+ except asyncio.CancelledError:
217
+ return None
218
+ finally:
219
+ session.pending.discard(future)
220
+ outcome = response.outcome
221
+ return outcome.option_id if getattr(outcome, "outcome", "") == "selected" else None
222
+
223
+ async def ask(self, question: str, options: list[str]) -> str:
224
+ if not options:
225
+ return (
226
+ "(this editor cannot take a typed answer; continue with your best judgement and "
227
+ "state your assumption)"
228
+ )
229
+ listed = [
230
+ schema.PermissionOption(option_id=f"opt{i}", name=text[:80], kind="allow_once")
231
+ for i, text in enumerate(options)
232
+ ]
233
+ tool_call = schema.ToolCallUpdate(
234
+ tool_call_id=f"ask-{abs(hash(question)) % 10**8}", title=question, status="pending"
235
+ )
236
+ chosen = await self._ask_client(tool_call, listed)
237
+ if chosen and chosen.startswith("opt") and chosen[3:].isdigit():
238
+ return options[int(chosen[3:])]
239
+ return ""
240
+
241
+ async def choose(self, title: str, options: list[tuple[str, str]]) -> str | None:
242
+ listed = [
243
+ schema.PermissionOption(option_id=f"opt{i}", name=label[:80], kind="allow_once")
244
+ for i, (_v, label) in enumerate(options[:12])
245
+ ]
246
+ tool_call = schema.ToolCallUpdate(
247
+ tool_call_id=f"choose-{abs(hash(title)) % 10**8}", title=title, status="pending"
248
+ )
249
+ chosen = await self._ask_client(tool_call, listed)
250
+ if chosen and chosen[3:].isdigit() and int(chosen[3:]) < len(options):
251
+ return options[int(chosen[3:])][0]
252
+ return None
253
+
254
+ async def confirm(self, question: str) -> bool:
255
+ return await self.choose(question, [("yes", "Yes"), ("no", "No")]) == "yes"
256
+
257
+ def show(self, text: str, *, markdown: bool = False) -> None:
258
+ self.agent.spawn(self._update(acp.update_agent_message_text(f"{text}\n\n")))
259
+
260
+ def error(self, text: str) -> None:
261
+ self.agent.spawn(self._update(acp.update_agent_message_text(f"**Error:** {text}\n\n")))
262
+
263
+ async def clear(self) -> None:
264
+ return None
265
+
266
+ async def replay(self, thread: Any) -> None:
267
+ for message in thread.messages:
268
+ role = message.get("role")
269
+ content = message.get("content")
270
+ blocks = content if isinstance(content, list) else [{"type": "text", "text": content}]
271
+ text = "".join(
272
+ str(b.get("text", ""))
273
+ for b in blocks
274
+ if isinstance(b, dict)
275
+ and b.get("type") == "text"
276
+ and not str(b.get("text", "")).startswith("<workspace>")
277
+ ).strip()
278
+ if not text:
279
+ continue
280
+ if role == "assistant":
281
+ await self._update(acp.update_agent_message_text(text))
282
+ elif role == "user" and prompt_text(message):
283
+ await self._update(acp.update_user_message_text(prompt_text(message)))
284
+
285
+
286
+ def _brief(arguments: dict[str, Any], limit: int = 70) -> str:
287
+ for key in ("path", "command", "query", "reference", "description"):
288
+ if isinstance(arguments.get(key), str):
289
+ first = (arguments[key].splitlines() or [""])[0]
290
+ return first[:limit]
291
+ text = json.dumps(arguments, default=str)
292
+ return text if len(text) <= limit else text[: limit - 1] + "…"
293
+
294
+
295
+ class TraceforgeAcpAgent:
296
+ """The ACP ``Agent`` for traceforge (see :class:`acp.Agent`)."""
297
+
298
+ def __init__(
299
+ self,
300
+ *,
301
+ client: Any = None,
302
+ store: SessionStore | None = None,
303
+ settings_factory: Any = None,
304
+ ):
305
+ self.model_client = client # injectable model client (tests)
306
+ self.store = store
307
+ self.settings_factory = settings_factory or (lambda cwd: Settings.load(cwd))
308
+ self.sessions: dict[str, AcpSession] = {}
309
+ self.conn: Any = None
310
+ self._tasks: set[asyncio.Task] = set()
311
+ self.client_capabilities: Any = None
312
+
313
+ # ------------------------------------------------------------ plumbing
314
+
315
+ def on_connect(self, conn: Any) -> None:
316
+ self.conn = conn
317
+
318
+ def spawn(self, coroutine: Any) -> None:
319
+ task = asyncio.ensure_future(coroutine)
320
+ self._tasks.add(task)
321
+ task.add_done_callback(self._tasks.discard)
322
+
323
+ def kind_of(self, session: AcpSession, name: str) -> str:
324
+ tool = session.chat.runtime.registry.get(name)
325
+ return tool_kind(name, tool.kind.value if tool else "other")
326
+
327
+ def _make_session(self, cwd: str, mode: str = "agent") -> AcpSession:
328
+ workspace = Path(cwd).expanduser().resolve()
329
+ settings = self.settings_factory(workspace)
330
+ ui = AcpUI(self)
331
+ chat = ChatSession(
332
+ workspace,
333
+ settings,
334
+ ui,
335
+ mode=mode,
336
+ store=self.store,
337
+ client=self.model_client,
338
+ terminal_extras=False, # the editor shows diffs and does its own review
339
+ )
340
+ session = AcpSession("", chat)
341
+ ui.session = session
342
+ return session
343
+
344
+ def _register(self, session: AcpSession, session_id: str) -> None:
345
+ session.id = session_id
346
+ self.sessions[session_id] = session
347
+
348
+ def _get(self, session_id: str) -> AcpSession:
349
+ try:
350
+ return self.sessions[session_id]
351
+ except KeyError:
352
+ raise acp.RequestError.invalid_params(
353
+ {"session_id": f"unknown session {session_id}"}
354
+ ) from None
355
+
356
+ def _modes(self, session: AcpSession) -> schema.SessionModeState:
357
+ return schema.SessionModeState(
358
+ current_mode_id=session.chat.mode,
359
+ available_modes=[
360
+ schema.SessionMode(id=m, name=_MODE_NAMES[m], description=MODE_COPY[m])
361
+ for m in MODES
362
+ ],
363
+ )
364
+
365
+ def _options(self, session: AcpSession) -> list[Any]:
366
+ from traceforge import models
367
+
368
+ chat = session.chat
369
+ model_options = models.picker_options(chat.settings.model) + [
370
+ (tier, f"{tier} tier") for tier in models.TIERS
371
+ ]
372
+ seen: set[str] = set()
373
+ unique = [(v, n) for v, n in model_options if not (v in seen or seen.add(v))]
374
+
375
+ def select(config_id: str, name: str, current: str, values: list[tuple[str, str]]):
376
+ return schema.SessionConfigOptionSelect(
377
+ id=config_id,
378
+ name=name,
379
+ type="select",
380
+ current_value=current,
381
+ options=[schema.SessionConfigSelectOption(value=v, name=n) for v, n in values],
382
+ )
383
+
384
+ return [
385
+ select("model", "Model", chat.settings.model, unique),
386
+ select("effort", "Effort", chat.settings.effort, [(e, e) for e in EFFORTS]),
387
+ select("approval", "Approvals", chat.settings.approval, [(a, a) for a in APPROVALS]),
388
+ ]
389
+
390
+ async def _announce_commands(self, session: AcpSession) -> None:
391
+ from traceforge.chat import command_list
392
+
393
+ commands = [
394
+ schema.AvailableCommand(name=c.name, description=c.summary) for c in command_list()
395
+ ] + [
396
+ schema.AvailableCommand(name=s.name, description=s.description)
397
+ for s in session.chat.skills.values()
398
+ ]
399
+ await self.conn.session_update(
400
+ session_id=session.id,
401
+ update=schema.AvailableCommandsUpdate(
402
+ available_commands=commands, session_update="available_commands_update"
403
+ ),
404
+ )
405
+
406
+ # ------------------------------------------------------------- protocol
407
+
408
+ async def initialize(
409
+ self,
410
+ protocol_version: int,
411
+ client_capabilities: Any = None,
412
+ client_info: Any = None,
413
+ **kwargs: Any,
414
+ ) -> schema.InitializeResponse:
415
+ self.client_capabilities = client_capabilities
416
+ return schema.InitializeResponse(
417
+ protocol_version=min(protocol_version, acp.PROTOCOL_VERSION),
418
+ agent_capabilities=schema.AgentCapabilities(
419
+ load_session=True,
420
+ prompt_capabilities=schema.PromptCapabilities(image=True, embedded_context=True),
421
+ session_capabilities=schema.SessionCapabilities(
422
+ list=schema.SessionListCapabilities()
423
+ ),
424
+ ),
425
+ agent_info=schema.Implementation(
426
+ name="traceforge", title="Traceforge", version=__version__
427
+ ),
428
+ auth_methods=[],
429
+ )
430
+
431
+ async def new_session(
432
+ self, cwd: str, additional_directories: Any = None, mcp_servers: Any = None, **kwargs: Any
433
+ ) -> schema.NewSessionResponse:
434
+ session = self._make_session(cwd)
435
+ chat = session.chat
436
+ assert chat.store is not None
437
+ thread = chat.store.create(chat.workspace, "ACP session", chat.mode, meta={"client": "acp"})
438
+ chat.thread = thread
439
+ self._register(session, thread.id)
440
+ self.spawn(self._announce_commands(session))
441
+ return schema.NewSessionResponse(
442
+ session_id=thread.id, modes=self._modes(session), config_options=self._options(session)
443
+ )
444
+
445
+ async def load_session(
446
+ self,
447
+ cwd: str,
448
+ session_id: str,
449
+ mcp_servers: Any = None,
450
+ additional_directories: Any = None,
451
+ **kwargs: Any,
452
+ ) -> schema.LoadSessionResponse | None:
453
+ session = self._make_session(cwd)
454
+ try:
455
+ await session.chat.resume(session_id) # replays the history to the client
456
+ except LookupError as exc:
457
+ raise acp.RequestError.invalid_params({"session_id": str(exc)}) from exc
458
+ assert session.chat.thread is not None
459
+ self._register(session, session_id)
460
+ self.spawn(self._announce_commands(session))
461
+ return schema.LoadSessionResponse(
462
+ modes=self._modes(session), config_options=self._options(session)
463
+ )
464
+
465
+ async def list_sessions(
466
+ self, cwd: str | None = None, cursor: str | None = None, **kwargs: Any
467
+ ) -> schema.ListSessionsResponse:
468
+ store = self.store or SessionStore()
469
+ threads = store.list(Path(cwd).resolve() if cwd else None, limit=50)
470
+ return schema.ListSessionsResponse(
471
+ sessions=[
472
+ schema.SessionInfo(session_id=t.id, cwd=t.workspace, title=t.title) for t in threads
473
+ ]
474
+ )
475
+
476
+ async def set_session_mode(
477
+ self, session_id: str, mode_id: str, **kwargs: Any
478
+ ) -> schema.SetSessionModeResponse | None:
479
+ session = self._get(session_id)
480
+ try:
481
+ await session.chat.set_mode(mode_id)
482
+ except ValueError as exc:
483
+ raise acp.RequestError.invalid_params({"mode_id": str(exc)}) from exc
484
+ return schema.SetSessionModeResponse()
485
+
486
+ async def set_config_option(
487
+ self, config_id: str, session_id: str, value: Any, **kwargs: Any
488
+ ) -> schema.SetSessionConfigOptionResponse | None:
489
+ chat = self._get(session_id).chat
490
+ try:
491
+ if config_id == "effort":
492
+ await chat.set_effort(str(value))
493
+ elif config_id == "model":
494
+ await chat.set_model(str(value))
495
+ elif config_id == "approval":
496
+ await chat.set_approval(str(value), confirm=False)
497
+ else:
498
+ raise ValueError(f"unknown option {config_id}")
499
+ except ValueError as exc:
500
+ raise acp.RequestError.invalid_params({"config_id": str(exc)}) from exc
501
+ return schema.SetSessionConfigOptionResponse(
502
+ config_options=self._options(self._get(session_id))
503
+ )
504
+
505
+ async def authenticate(self, method_id: str, **kwargs: Any) -> None:
506
+ return None
507
+
508
+ async def prompt(
509
+ self, session_id: str, prompt: list[Any], **kwargs: Any
510
+ ) -> schema.PromptResponse:
511
+ session = self._get(session_id)
512
+ chat = session.chat
513
+ if chat.busy:
514
+ raise acp.RequestError.invalid_request({"session_id": "a turn is already running"})
515
+ session.cancelled = False
516
+ text, images, problems = prompt_to_text(prompt)
517
+ for problem in problems:
518
+ chat.ui.error(problem)
519
+ chat.pending_images.extend(images)
520
+ if not text and not images:
521
+ return schema.PromptResponse(stop_reason="end_turn")
522
+ before = chat.usage.to_dict() if hasattr(chat.usage, "to_dict") else {}
523
+ result = await chat.submit(text or "(see the attached image)")
524
+ if session.cancelled:
525
+ return schema.PromptResponse(stop_reason="cancelled")
526
+ usage = None
527
+ if result is not None:
528
+ u = result.usage
529
+ usage = schema.Usage(
530
+ total_tokens=u.input_tokens + u.output_tokens,
531
+ input_tokens=u.input_tokens,
532
+ output_tokens=u.output_tokens,
533
+ cached_read_tokens=u.cache_read_input_tokens or None,
534
+ )
535
+ del before
536
+ stop = _STOP.get(result.stop_reason or "end_turn", "end_turn") if result else "end_turn"
537
+ return schema.PromptResponse(stop_reason=stop, usage=usage)
538
+
539
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
540
+ session = self.sessions.get(session_id)
541
+ if session is None:
542
+ return
543
+ session.cancelled = True
544
+ for future in list(session.pending):
545
+ future.cancel()
546
+ await session.chat.cancel()
547
+
548
+ async def close_session(self, session_id: str, **kwargs: Any) -> None:
549
+ session = self.sessions.pop(session_id, None)
550
+ if session is not None:
551
+ await session.chat.cancel()
552
+ await session.chat.processes.kill_all()
553
+
554
+ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
555
+ raise acp.RequestError.method_not_found(method)
556
+
557
+ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
558
+ return None
559
+
560
+
561
+ def run_acp(workspace: Path, approval: str = "ask") -> None:
562
+ """Run the agent on stdin/stdout until the client disconnects (``traceforge acp``)."""
563
+
564
+ def settings_for(cwd: Path) -> Settings:
565
+ return Settings.load(cwd, {"approval": approval})
566
+
567
+ async def serve() -> None:
568
+ await acp.run_agent(TraceforgeAcpAgent(settings_factory=settings_for))
569
+
570
+ asyncio.run(serve())