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/agent.py ADDED
@@ -0,0 +1,680 @@
1
+ """The agent loop: Claude + tools, streaming, with append-only history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import re
9
+ import time
10
+ from collections.abc import Awaitable, Callable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Protocol
14
+
15
+ import anthropic
16
+
17
+ from traceforge import context as ctxmod
18
+ from traceforge.approval import ApprovalPolicy
19
+ from traceforge.config import Settings
20
+ from traceforge.prompts import system_prompt
21
+ from traceforge.tools.base import (
22
+ Registry,
23
+ Tool,
24
+ ToolContext,
25
+ ToolError,
26
+ ToolKind,
27
+ ToolOutput,
28
+ render,
29
+ spill,
30
+ to_text,
31
+ validate_arguments,
32
+ )
33
+ from traceforge.tools.batch import (
34
+ EXCLUSIVE,
35
+ READ,
36
+ WRITE,
37
+ CallPlan,
38
+ path_key,
39
+ read_only_command,
40
+ run_batch,
41
+ )
42
+
43
+ # A tool result is plain text, or a list of content blocks (text and images).
44
+ ToolContent = str | list[dict[str, Any]]
45
+
46
+ FALLBACK_BETA = "server-side-fallback-2026-07-01"
47
+ # Models that accept adaptive thinking + effort and server-side "default" fallbacks.
48
+ _FALLBACK_MODELS = {"claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5-5"}
49
+ _LEGACY_THINKING = ("claude-haiku-4", "claude-sonnet-4-5", "claude-opus-4-5", "claude-3")
50
+
51
+
52
+ @dataclass
53
+ class AgentEvent:
54
+ kind: str # text | thinking | tool_call | tool_result | usage | status | error | done
55
+ data: Any = None
56
+
57
+
58
+ EventSink = Callable[[AgentEvent], Awaitable[None] | None]
59
+
60
+
61
+ async def _emit(sink: EventSink | None, kind: str, data: Any = None) -> None:
62
+ if sink is None:
63
+ return
64
+ result = sink(AgentEvent(kind, data))
65
+ if asyncio.iscoroutine(result):
66
+ await result
67
+
68
+
69
+ class AgentError(RuntimeError):
70
+ """The model call failed in a way the loop cannot recover from."""
71
+
72
+
73
+ log = logging.getLogger("traceforge.agent")
74
+
75
+ # Errors that mean the prompt no longer fits the model's context window.
76
+ _OVERFLOW = re.compile(
77
+ r"prompt is too long|context.length.exceeded|maximum context length|too many tokens|"
78
+ r"context window|input is too long",
79
+ re.IGNORECASE,
80
+ )
81
+
82
+
83
+ class ToolExecutor(Protocol):
84
+ def definitions(self) -> list[dict[str, Any]]: ...
85
+
86
+ async def execute(
87
+ self, name: str, arguments: dict[str, Any], tool_use_id: str
88
+ ) -> tuple[ToolContent, bool]: ...
89
+
90
+ def parallel_safe(self, name: str) -> bool: ...
91
+
92
+
93
+ def plan_call(tool: Tool | None, arguments: dict[str, Any], workspace: str | None) -> CallPlan:
94
+ """How a call may be scheduled alongside the other calls of the same model step."""
95
+ if tool is None:
96
+ return CallPlan(EXCLUSIVE)
97
+ if tool.schedule is not None:
98
+ kind = tool.schedule(arguments)
99
+ elif tool.kind is ToolKind.READ:
100
+ kind = READ
101
+ elif tool.kind is ToolKind.WRITE:
102
+ kind = WRITE
103
+ elif tool.kind is ToolKind.SHELL and read_only_command(arguments.get("command")):
104
+ kind = READ
105
+ else:
106
+ kind = EXCLUSIVE
107
+ target = arguments.get(tool.target_arg or "path") if kind in (READ, WRITE) else None
108
+ return CallPlan(kind, path_key(target, workspace))
109
+
110
+
111
+ class LocalExecutor:
112
+ """Runs tools in this process, gated by the approval policy."""
113
+
114
+ def __init__(self, registry: Registry, context: ToolContext, policy: ApprovalPolicy):
115
+ self.registry = registry
116
+ self.context = context
117
+ self.policy = policy
118
+
119
+ def definitions(self) -> list[dict[str, Any]]:
120
+ return self.registry.definitions(eager=True)
121
+
122
+ def parallel_safe(self, name: str) -> bool:
123
+ tool = self.registry.get(name)
124
+ return tool is not None and tool.kind is ToolKind.READ
125
+
126
+ def plan(self, name: str, arguments: dict[str, Any]) -> CallPlan:
127
+ return plan_call(self.registry.get(name), arguments, str(self.context.workspace))
128
+
129
+ async def execute(
130
+ self, name: str, arguments: dict[str, Any], tool_use_id: str
131
+ ) -> tuple[ToolContent, bool]:
132
+ tool = self.registry.get(name)
133
+ if tool is None:
134
+ known = ", ".join(sorted(self.registry.names()))
135
+ return f"unknown tool {name!r}; available tools: {known}", True
136
+ problems = validate_arguments(tool.input_schema, arguments)
137
+ if problems:
138
+ return "invalid arguments: " + "; ".join(problems), True
139
+ allowed, reason = await self.policy.check(tool, arguments)
140
+ if not allowed:
141
+ return f"not run: {reason}", True
142
+ try:
143
+ result = await tool.call(arguments, self.context)
144
+ except ToolError as exc:
145
+ return to_text(str(exc)), True
146
+ except Exception as exc: # a tool bug must not end the session
147
+ return to_text(f"{type(exc).__name__}: {exc}"), True
148
+ if isinstance(result, ToolOutput) and result.images:
149
+ return result.blocks(), False
150
+ text = render(result.text if isinstance(result, ToolOutput) else result)
151
+ if tool.spill:
152
+ return spill(text, self.context, name, tool_use_id), False
153
+ return to_text(text), False
154
+
155
+
156
+ _USAGE_FIELDS = (
157
+ "input_tokens",
158
+ "output_tokens",
159
+ "cache_read_input_tokens",
160
+ "cache_creation_input_tokens",
161
+ )
162
+
163
+
164
+ def _model_of(response: Any, fallback: Any) -> str:
165
+ """The model that answered: what the response says, else the route that was asked."""
166
+ name = getattr(response, "model", None)
167
+ if isinstance(name, str) and name:
168
+ return name
169
+ return str(getattr(fallback, "model", fallback) or "")
170
+
171
+
172
+ @dataclass
173
+ class Usage:
174
+ input_tokens: int = 0
175
+ output_tokens: int = 0
176
+ cache_read_input_tokens: int = 0
177
+ cache_creation_input_tokens: int = 0
178
+ requests: int = 0
179
+
180
+ def __post_init__(self) -> None:
181
+ # Tokens per model id (not a dataclass field, so code that sums the counters
182
+ # above keeps working); the cost meter prices each model separately.
183
+ self.by_model: dict[str, dict[str, int]] = {}
184
+
185
+ def add(self, usage: Any, model: str = "") -> None:
186
+ self.requests += 1
187
+ for name in _USAGE_FIELDS:
188
+ setattr(self, name, getattr(self, name) + int(getattr(usage, name, 0) or 0))
189
+ if model:
190
+ slot = self.by_model.setdefault(model, dict.fromkeys((*_USAGE_FIELDS, "requests"), 0))
191
+ slot["requests"] += 1
192
+ for name in _USAGE_FIELDS:
193
+ slot[name] += int(getattr(usage, name, 0) or 0)
194
+
195
+ def merge(self, other: Usage) -> None:
196
+ for name in (*_USAGE_FIELDS, "requests"):
197
+ setattr(self, name, getattr(self, name) + getattr(other, name))
198
+ for model, counts in other.by_model.items():
199
+ slot = self.by_model.setdefault(model, dict.fromkeys(counts, 0))
200
+ for name, value in counts.items():
201
+ slot[name] = slot.get(name, 0) + value
202
+
203
+ def to_dict(self) -> dict[str, Any]:
204
+ data: dict[str, Any] = {name: getattr(self, name) for name in (*_USAGE_FIELDS, "requests")}
205
+ if self.by_model:
206
+ data["by_model"] = {m: dict(c) for m, c in self.by_model.items()}
207
+ return data
208
+
209
+
210
+ @dataclass
211
+ class TurnResult:
212
+ text: str
213
+ stop_reason: str | None
214
+ usage: Usage
215
+ tool_calls: int = 0
216
+ refusal: dict | None = None
217
+ messages_added: int = 0
218
+
219
+
220
+ @dataclass
221
+ class Agent:
222
+ settings: Settings
223
+ executor: ToolExecutor
224
+ mode: str = "agent"
225
+ client: Any = None # anthropic.AsyncAnthropic or a compatible fake
226
+ extra_instructions: str = ""
227
+ usage: Usage = field(default_factory=Usage)
228
+ # Context management: the summary/cutoff for the current thread, the todo list,
229
+ # and where full transcripts of compacted history are written.
230
+ compaction: ctxmod.Compaction = field(default_factory=ctxmod.Compaction)
231
+ todos: list[dict[str, Any]] = field(default_factory=list)
232
+ history_dir: Path | None = None
233
+ retry_delays: tuple[float, ...] = (2.0, 5.0, 15.0)
234
+
235
+ def __post_init__(self) -> None:
236
+ from traceforge import routing
237
+
238
+ self.route = routing.route(self.settings, routing.role_for_mode(self.mode))
239
+ self.escalated: routing.Route | None = None # set for the rest of a failing turn
240
+ if self.client is None:
241
+ from traceforge.providers import make_client, provider_for
242
+
243
+ self.client = make_client(
244
+ provider_for(self.settings.model, self.settings.provider), self.settings
245
+ )
246
+ self.system = system_prompt(
247
+ self.mode, self.extra_instructions or self.settings.extra_instructions
248
+ )
249
+
250
+ # ---------------------------------------------------------------- request
251
+ @property
252
+ def current_route(self):
253
+ return self.escalated or self.route
254
+
255
+ def _request(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
256
+ model = self.current_route.model
257
+ request: dict[str, Any] = {
258
+ "model": model,
259
+ "max_tokens": self.settings.max_tokens,
260
+ "system": [{"type": "text", "text": self.system}],
261
+ "tools": self.executor.definitions(),
262
+ "messages": messages,
263
+ "cache_control": {"type": "ephemeral"},
264
+ }
265
+ if not model.startswith(_LEGACY_THINKING):
266
+ request["thinking"] = {"type": "adaptive", "display": self.settings.thinking_display}
267
+ request["output_config"] = {"effort": self.current_route.effort}
268
+ if self.settings.use_fallbacks() and model in _FALLBACK_MODELS:
269
+ request["extra_headers"] = {"anthropic-beta": FALLBACK_BETA}
270
+ request["extra_body"] = {"fallbacks": "default"}
271
+ return request
272
+
273
+ async def _stream_once(self, messages: list[dict[str, Any]], sink: EventSink | None):
274
+ request = self._request(messages)
275
+ attempts = 0
276
+ retries = 0
277
+ while True:
278
+ try:
279
+ async with self.client.messages.stream(**request) as stream:
280
+ async for event in stream:
281
+ etype = getattr(event, "type", None)
282
+ if etype == "text":
283
+ await _emit(sink, "text", event.text)
284
+ elif etype == "thinking":
285
+ await _emit(sink, "thinking", event.thinking)
286
+ return await stream.get_final_message()
287
+ except ValueError:
288
+ # Tool input JSON the SDK could not parse at all: re-issue the turn (bounded).
289
+ attempts += 1
290
+ if attempts > 2:
291
+ raise AgentError(
292
+ "the model produced unparseable tool input three times"
293
+ ) from None
294
+ await _emit(sink, "status", "retrying a malformed tool call")
295
+ except TypeError as exc:
296
+ if "authentication method" not in str(exc):
297
+ raise
298
+ raise AgentError(
299
+ "No API key found. Set ANTHROPIC_API_KEY (or run `ant auth login`), put it in a "
300
+ ".env file, or use a hosted server with --server."
301
+ ) from exc
302
+ except anthropic.AuthenticationError as exc:
303
+ raise AgentError(
304
+ "Authentication failed. Set ANTHROPIC_API_KEY (or run `ant auth login`), "
305
+ "or use a hosted server with --server."
306
+ ) from exc
307
+ except anthropic.BadRequestError as exc:
308
+ if "fallbacks" in request.get("extra_body", {}) and "fallback" in str(exc).lower():
309
+ request.pop("extra_body", None)
310
+ request.pop("extra_headers", None)
311
+ await _emit(
312
+ sink, "status", "server-side fallback unavailable; continuing without it"
313
+ )
314
+ continue
315
+ raise AgentError(f"The API rejected the request: {exc.message}") from exc
316
+ except anthropic.RateLimitError as exc:
317
+ if await self._retry_wait(retries, sink, "rate limited"):
318
+ retries += 1
319
+ continue
320
+ raise AgentError(
321
+ f"Rate limited by the API: {exc.message}. Try again shortly."
322
+ ) from exc
323
+ except anthropic.APIStatusError as exc:
324
+ transient = exc.status_code >= 500 or exc.status_code in (408, 409)
325
+ if transient and await self._retry_wait(
326
+ retries, sink, f"API error {exc.status_code}"
327
+ ):
328
+ retries += 1
329
+ continue
330
+ raise AgentError(f"API error {exc.status_code}: {exc.message}") from exc
331
+ except anthropic.APIConnectionError as exc:
332
+ if await self._retry_wait(retries, sink, "connection dropped"):
333
+ retries += 1
334
+ continue
335
+ raise AgentError(f"Could not reach the API: {exc}") from exc
336
+
337
+ async def _retry_wait(self, retries: int, sink: EventSink | None, why: str) -> bool:
338
+ """Wait before retrying a transient failure; False once the retries are used up.
339
+
340
+ Nothing has been appended to the history yet, so repeating the call is safe.
341
+ """
342
+ if retries >= len(self.retry_delays):
343
+ return False
344
+ delay = self.retry_delays[retries]
345
+ await _emit(
346
+ sink,
347
+ "status",
348
+ f"{why}; retrying in {delay:g}s ({retries + 1}/{len(self.retry_delays)})",
349
+ )
350
+ await asyncio.sleep(delay)
351
+ return True
352
+
353
+ # ----------------------------------------------------------------- context
354
+ def context_window(self) -> int:
355
+ return self.settings.context_window_tokens()
356
+
357
+ def _view(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
358
+ return ctxmod.view(messages, self.compaction, self.todos)
359
+
360
+ def _prompt_tokens(self, view: list[dict[str, Any]]) -> int:
361
+ tools = len(json.dumps(self.executor.definitions(), default=str))
362
+ return ctxmod.estimate_tokens(view) + (len(self.system) + tools) // ctxmod.CHARS_PER_TOKEN
363
+
364
+ async def _maybe_compact(
365
+ self, messages: list[dict[str, Any]], sink: EventSink | None, *, force: bool = False
366
+ ) -> bool:
367
+ window = self.context_window()
368
+ limit = int(window * self.settings.compact_at)
369
+ used = self._prompt_tokens(self._view(messages))
370
+ if not force and used < limit:
371
+ return False
372
+ return await self.compact(messages, sink, reason=f"~{used} tokens of {window}")
373
+
374
+ async def compact(
375
+ self, messages: list[dict[str, Any]], sink: EventSink | None = None, reason: str = ""
376
+ ) -> bool:
377
+ """Summarise older history so the view fits again. Returns False if nothing to do."""
378
+ keep = int(self.context_window() * self.settings.compact_keep)
379
+ cutoff = ctxmod.choose_cutoff(messages, self.compaction.cutoff, keep)
380
+ if cutoff is None:
381
+ return False
382
+ older = messages[self.compaction.cutoff : cutoff]
383
+ await _emit(
384
+ sink,
385
+ "status",
386
+ f"compacting context ({reason or 'requested'}): summarising {len(older)} messages",
387
+ )
388
+ transcript = ctxmod.render_transcript(older)
389
+ if self.compaction.summary:
390
+ transcript = (
391
+ "Summary of the history before these messages:\n"
392
+ + self.compaction.summary
393
+ + "\n\n# Messages since that summary\n\n"
394
+ + transcript
395
+ )
396
+ budget = int(self.context_window() * 0.5) * ctxmod.CHARS_PER_TOKEN
397
+ if len(transcript) > budget:
398
+ transcript = "[older part omitted]\n" + transcript[-budget:]
399
+ if self.todos:
400
+ transcript += "\n\nCurrent todo list:\n" + json.dumps(self.todos, indent=1)
401
+ archive = None
402
+ if self.history_dir is not None:
403
+ try:
404
+ self.history_dir.mkdir(parents=True, exist_ok=True)
405
+ stamp = time.strftime("%Y%m%d-%H%M%S")
406
+ archive = self.history_dir / f"history-{stamp}-{self.compaction.count + 1}.md"
407
+ archive.write_text(
408
+ ctxmod.render_transcript(older, result_chars=0, input_chars=0),
409
+ encoding="utf-8",
410
+ )
411
+ except OSError:
412
+ archive = None
413
+ from traceforge import routing
414
+
415
+ compact = routing.route(self.settings, "compact")
416
+ request = {
417
+ "model": compact.model,
418
+ "max_tokens": min(self.settings.max_tokens, 12_000),
419
+ "system": [{"type": "text", "text": ctxmod.SUMMARY_SYSTEM}],
420
+ "messages": [
421
+ {
422
+ "role": "user",
423
+ "content": [
424
+ {
425
+ "type": "text",
426
+ "text": "Summarise this working history.\n\n<history>\n"
427
+ + transcript
428
+ + "\n</history>",
429
+ }
430
+ ],
431
+ }
432
+ ],
433
+ }
434
+ try:
435
+ async with self.client.messages.stream(**request) as stream:
436
+ async for _ in stream:
437
+ pass
438
+ response = await stream.get_final_message()
439
+ except Exception as exc: # compaction failing must not end the session
440
+ log.warning("compaction failed: %s", exc)
441
+ await _emit(sink, "status", f"context compaction failed: {exc}")
442
+ return False
443
+ self.usage.add(response.usage, _model_of(response, self.route.model))
444
+ summary = "".join(
445
+ getattr(b, "text", "") for b in response.content if getattr(b, "type", "") == "text"
446
+ ).strip()
447
+ if not summary:
448
+ return False
449
+ files = list(self.compaction.history_files)
450
+ if archive is not None:
451
+ files.append(self._display(archive))
452
+ self.compaction = ctxmod.Compaction(cutoff, summary, files, self.compaction.count + 1)
453
+ await _emit(
454
+ sink,
455
+ "compacted",
456
+ {"cutoff": cutoff, "messages_summarised": len(older), "archive": files[-1:] or None},
457
+ )
458
+ return True
459
+
460
+ def _display(self, path: Path) -> str:
461
+ workspace = getattr(getattr(self.executor, "context", None), "workspace", None)
462
+ if workspace is not None:
463
+ try:
464
+ return path.relative_to(workspace).as_posix()
465
+ except ValueError:
466
+ pass
467
+ return str(path)
468
+
469
+ # --------------------------------------------------------------------- run
470
+ async def run(
471
+ self,
472
+ messages: list[dict[str, Any]],
473
+ user_content: str | list[dict[str, Any]],
474
+ sink: EventSink | None = None,
475
+ checkpoint: Callable[[list[dict[str, Any]]], None] | None = None,
476
+ ) -> TurnResult:
477
+ """Run one user turn to completion. ``messages`` is extended in place."""
478
+ start = len(messages)
479
+ content = (
480
+ [{"type": "text", "text": user_content}]
481
+ if isinstance(user_content, str)
482
+ else user_content
483
+ )
484
+ # A turn that crashed or was killed may have left tool calls unanswered.
485
+ _close_dangling_tool_uses(messages)
486
+ if messages and messages[-1]["role"] == "user":
487
+ # e.g. after an interruption left tool results as the last message
488
+ messages[-1]["content"].extend(content)
489
+ else:
490
+ messages.append({"role": "user", "content": content})
491
+ turn_usage = Usage()
492
+ tool_calls = 0
493
+ final_text: list[str] = []
494
+ stop_reason: str | None = None
495
+ refusal = None
496
+ self.escalated = None
497
+ failures = 0 # consecutive failed tool calls in this turn
498
+ try:
499
+ for _ in range(self.settings.max_turns):
500
+ await self._maybe_compact(messages, sink)
501
+ try:
502
+ response = await self._stream_once(self._view(messages), sink)
503
+ except AgentError as exc:
504
+ # The prompt no longer fits: compact harder and try once more.
505
+ if not _OVERFLOW.search(str(exc)) or not await self._maybe_compact(
506
+ messages, sink, force=True
507
+ ):
508
+ raise
509
+ response = await self._stream_once(self._view(messages), sink)
510
+ messages.append(
511
+ {"role": "assistant", "content": [b.to_dict() for b in response.content]}
512
+ )
513
+ turn_usage.add(response.usage)
514
+ self.usage.add(response.usage, _model_of(response, self.escalated or self.route))
515
+ await _emit(sink, "usage", turn_usage.to_dict())
516
+ stop_reason = response.stop_reason
517
+ if checkpoint:
518
+ checkpoint(messages)
519
+ if stop_reason == "pause_turn":
520
+ continue
521
+ if stop_reason == "refusal":
522
+ details = getattr(response, "stop_details", None)
523
+ refusal = {
524
+ "category": getattr(details, "category", None),
525
+ "explanation": getattr(details, "explanation", None),
526
+ }
527
+ await _emit(
528
+ sink,
529
+ "error",
530
+ f"The model declined this request ({refusal['category'] or 'policy'}).",
531
+ )
532
+ break
533
+ text = "".join(b.text for b in response.content if b.type == "text")
534
+ if text:
535
+ final_text = [text]
536
+ tool_uses = [b for b in response.content if b.type == "tool_use"]
537
+ if not tool_uses:
538
+ break
539
+ tool_calls += len(tool_uses)
540
+ if stop_reason == "max_tokens":
541
+ results = [
542
+ {
543
+ "type": "tool_result",
544
+ "tool_use_id": b.id,
545
+ "is_error": True,
546
+ "content": "Not run: the output hit max_tokens and this tool input was cut off. "
547
+ "Split the change into smaller steps.",
548
+ }
549
+ for b in tool_uses
550
+ ]
551
+ else:
552
+ results = await self._run_tools(tool_uses, sink)
553
+ for result in results:
554
+ failures = failures + 1 if result.get("is_error") else 0
555
+ await self._maybe_escalate(failures, sink)
556
+ for block in tool_uses:
557
+ if block.name == "write_todos" and isinstance(block.input, dict):
558
+ self.todos = list(block.input.get("todos") or [])
559
+ messages.append({"role": "user", "content": results})
560
+ if checkpoint:
561
+ checkpoint(messages)
562
+ else:
563
+ await _emit(sink, "status", f"stopped after {self.settings.max_turns} model calls")
564
+ except (asyncio.CancelledError, KeyboardInterrupt):
565
+ _close_dangling_tool_uses(messages)
566
+ if checkpoint:
567
+ checkpoint(messages)
568
+ raise
569
+ result = TurnResult(
570
+ "\n".join(final_text).strip(),
571
+ stop_reason,
572
+ turn_usage,
573
+ tool_calls,
574
+ refusal,
575
+ len(messages) - start,
576
+ )
577
+ await _emit(sink, "done", {"stop_reason": stop_reason, "usage": turn_usage.to_dict()})
578
+ return result
579
+
580
+ def _plan(self, block: Any) -> CallPlan:
581
+ planner = getattr(self.executor, "plan", None)
582
+ if planner is not None and isinstance(block.input, dict):
583
+ return planner(block.name, block.input)
584
+ return CallPlan(READ) if self.executor.parallel_safe(block.name) else CallPlan(EXCLUSIVE)
585
+
586
+ async def _maybe_escalate(self, failures: int, sink: EventSink | None) -> None:
587
+ """After repeated tool failures, run the rest of the turn on the escalate role."""
588
+ from traceforge import routing
589
+
590
+ if self.escalated is not None or failures < routing.ESCALATE_AFTER:
591
+ return
592
+ if self.route.role not in {"main", "general"}:
593
+ return
594
+ if self.settings.model_explicit and "escalate" not in (self.settings.routes or {}):
595
+ return # the user chose the model; do not switch it behind their back
596
+ target = routing.route(self.settings, "escalate")
597
+ if target.model == self.route.model and target.effort == self.route.effort:
598
+ return
599
+ self.escalated = target
600
+ await _emit(
601
+ sink,
602
+ "status",
603
+ f"{failures} tool calls failed in a row; switching to {target.model} "
604
+ f"({target.effort} effort) for the rest of this task",
605
+ )
606
+
607
+ async def _run_tools(
608
+ self, tool_uses: list[Any], sink: EventSink | None
609
+ ) -> list[dict[str, Any]]:
610
+ async def one(block: Any) -> dict[str, Any]:
611
+ arguments = block.input if isinstance(block.input, dict) else {}
612
+ await _emit(sink, "tool_call", {"id": block.id, "name": block.name, "input": arguments})
613
+ if not isinstance(block.input, dict):
614
+ content, is_error = json.dumps({"INVALID_JSON": str(block.input)[:2000]}), True
615
+ else:
616
+ content, is_error = await self.executor.execute(block.name, arguments, block.id)
617
+ await _emit(
618
+ sink,
619
+ "tool_result",
620
+ {
621
+ "id": block.id,
622
+ "name": block.name,
623
+ "content": content_text(content),
624
+ "is_error": is_error,
625
+ },
626
+ )
627
+ if not is_error and block.name == "write_todos":
628
+ await _emit(sink, "todos", arguments.get("todos", []))
629
+ result: dict[str, Any] = {
630
+ "type": "tool_result",
631
+ "tool_use_id": block.id,
632
+ "content": content,
633
+ }
634
+ if is_error:
635
+ result["is_error"] = True
636
+ return result
637
+
638
+ plans = [self._plan(b) for b in tool_uses]
639
+ return await run_batch(tool_uses, plans, one)
640
+
641
+
642
+ def content_text(content: Any) -> str:
643
+ """The text of a tool result (images are counted, not shown)."""
644
+ if isinstance(content, str):
645
+ return content
646
+ texts, images = [], 0
647
+ for block in content or []:
648
+ if isinstance(block, dict) and block.get("type") == "text":
649
+ texts.append(str(block.get("text", "")))
650
+ elif isinstance(block, dict) and block.get("type") == "image":
651
+ images += 1
652
+ if images:
653
+ texts.append(f"[{images} image{'s' if images != 1 else ''}]")
654
+ return "\n".join(texts)
655
+
656
+
657
+ def _close_dangling_tool_uses(messages: list[dict[str, Any]]) -> None:
658
+ """After an interruption, answer any unanswered tool_use so the history stays valid."""
659
+ if not messages or messages[-1]["role"] != "assistant":
660
+ return
661
+ pending = [
662
+ b["id"]
663
+ for b in messages[-1]["content"]
664
+ if isinstance(b, dict) and b.get("type") == "tool_use"
665
+ ]
666
+ if pending:
667
+ messages.append(
668
+ {
669
+ "role": "user",
670
+ "content": [
671
+ {
672
+ "type": "tool_result",
673
+ "tool_use_id": tid,
674
+ "is_error": True,
675
+ "content": "interrupted by the user",
676
+ }
677
+ for tid in pending
678
+ ],
679
+ }
680
+ )
@@ -0,0 +1,6 @@
1
+ """Read Altium Designer and CircuitMaker projects (``.PrjPcb``, ``.SchDoc``, ``.PcbDoc``)."""
2
+
3
+ from traceforge.altium.cfb import AltiumError
4
+ from traceforge.altium.project import AltiumProject, find_projects, load_project
5
+
6
+ __all__ = ["AltiumError", "AltiumProject", "find_projects", "load_project"]