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
@@ -0,0 +1,33 @@
1
+ """Hosted mode: the model runs on a traceforge server, tools run on the user's machine.
2
+
3
+ Protocol (JSON over one WebSocket per turn, ``/v1/agent``):
4
+
5
+ client -> server {"type": "turn", "protocol": 2, "mode": "agent", "effort": "high",
6
+ "model": "...optional, one of GET /v1/models...",
7
+ "messages": [...history...], "content": [...new user content blocks...],
8
+ "tools": [...tool definitions...], "parallel_safe": [...read-only tool names...],
9
+ "instructions": "...project instructions..."}
10
+ server -> client {"type": "event", "kind": "text" | "thinking" | "status" | "usage" | "error"
11
+ | "notice", "data": ...}
12
+ server -> client {"type": "checkpoint", "start": n, "messages": [...history from index n...]}
13
+ server -> client {"type": "tool_request", "id": "...", "name": "...", "input": {...}}
14
+ client -> server {"type": "tool_result", "id": "...", "content": "...", "is_error": false}
15
+ client -> server {"type": "cancel"}
16
+ server -> client {"type": "done", "messages": [...full history...], "usage": {...}, "text": "...",
17
+ "stop_reason": "...", "refusal": null}
18
+ server -> client {"type": "error", "code": "...", "message": "...", "retryable": false}
19
+
20
+ The server is stateless: the conversation history lives with the client. A
21
+ ``checkpoint`` frame follows every model step (protocol 2), so a client whose
22
+ connection drops mid-turn resumes from the last checkpoint with the tool results
23
+ it already has, instead of starting over. Tool requests for tools listed in
24
+ ``parallel_safe`` may arrive several at a time. Protocol-1 clients get no
25
+ checkpoints and no parallel requests.
26
+
27
+ HTTP endpoints (same ``Authorization: Bearer`` key): ``GET /health``,
28
+ ``GET /v1/models`` (models a client may request) and ``GET /v1/me`` (key name,
29
+ tokens used this month, limit and reset date).
30
+ """
31
+
32
+ PROTOCOL_VERSION = 2
33
+ SUPPORTED_PROTOCOLS = (1, 2)
@@ -0,0 +1,422 @@
1
+ """Client side of hosted mode: runs tools locally for a remote agent.
2
+
3
+ Durability: the server sends a ``checkpoint`` frame with the new history after
4
+ every model step, and the client saves it in the thread. If the connection drops
5
+ in the middle of a turn (a server restart, a proxy timeout, a laptop changing
6
+ networks), the client reconnects with backoff and resumes from the last
7
+ checkpoint: tool results it already computed are sent back instead of running the
8
+ tools again, and the agent is told to continue where it left off. Failures that
9
+ retrying cannot fix (a revoked key, a used-up quota, an invalid request) stop at once.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from traceforge.agent import (
22
+ AgentError,
23
+ AgentEvent,
24
+ EventSink,
25
+ LocalExecutor,
26
+ TurnResult,
27
+ Usage,
28
+ _close_dangling_tool_uses,
29
+ _emit,
30
+ )
31
+ from traceforge.approval import ApprovalMode, ApprovalPolicy, Approver
32
+ from traceforge.config import Settings
33
+ from traceforge.hooks import HookedExecutor, HookRunner
34
+ from traceforge.remote import PROTOCOL_VERSION
35
+ from traceforge.runtime import TurnLoop, project_instructions, registry_for_mode
36
+ from traceforge.sessions import SessionStore, Thread
37
+ from traceforge.tools import ToolContext
38
+ from traceforge.tools.base import QuestionHandler
39
+
40
+ MAX_ATTEMPTS = 4
41
+ RESUME_TEXT = (
42
+ "The connection to the traceforge server dropped in the middle of this turn and has been "
43
+ "restored. The history above is everything that completed. Continue the task where you left "
44
+ "off; do not repeat steps that already succeeded."
45
+ )
46
+
47
+
48
+ def websocket_url(base: str) -> str:
49
+ base = base.rstrip("/")
50
+ if base.startswith("https://"):
51
+ return "wss://" + base[len("https://") :] + "/v1/agent"
52
+ if base.startswith("http://"):
53
+ return "ws://" + base[len("http://") :] + "/v1/agent"
54
+ return base + "/v1/agent"
55
+
56
+
57
+ def http_url(base: str, path: str) -> str:
58
+ base = base.rstrip("/")
59
+ if base.startswith("wss://"):
60
+ base = "https://" + base[len("wss://") :]
61
+ elif base.startswith("ws://"):
62
+ base = "http://" + base[len("ws://") :]
63
+ return base + path
64
+
65
+
66
+ def backoff_schedule() -> list[float]:
67
+ raw = os.environ.get("TRACEFORGE_RETRY_BACKOFF", "2,5,10,20")
68
+ try:
69
+ values = [max(0.0, float(item)) for item in raw.split(",") if item.strip()]
70
+ except ValueError:
71
+ values = []
72
+ return values or [2.0, 5.0, 10.0, 20.0]
73
+
74
+
75
+ def idle_timeout() -> float | None:
76
+ """Seconds the server may stay silent mid-turn (``TRACEFORGE_IDLE_TIMEOUT``, 0 = forever)."""
77
+ try:
78
+ value = float(os.environ.get("TRACEFORGE_IDLE_TIMEOUT", "900"))
79
+ except ValueError:
80
+ value = 900.0
81
+ return value if value > 0 else None
82
+
83
+
84
+ class RemoteError(AgentError):
85
+ def __init__(self, message: str, *, kind: str = "server", retryable: bool = False):
86
+ super().__init__(message)
87
+ self.kind = kind
88
+ self.retryable = retryable
89
+
90
+
91
+ def classify(exc: BaseException) -> RemoteError:
92
+ """Map a transport failure onto (kind, retryable)."""
93
+ if isinstance(exc, RemoteError):
94
+ return exc
95
+ try:
96
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatus
97
+ except ImportError: # pragma: no cover
98
+ ConnectionClosed = InvalidHandshake = InvalidStatus = () # type: ignore[assignment]
99
+ if isinstance(exc, InvalidStatus):
100
+ status = exc.response.status_code
101
+ message = f"server refused the connection: HTTP {status}"
102
+ if status in (401, 403):
103
+ return RemoteError(message, kind="authentication")
104
+ if status == 429 or status >= 500:
105
+ return RemoteError(message, kind="server", retryable=True)
106
+ return RemoteError(message, kind="invalid_request")
107
+ if isinstance(exc, ConnectionClosed):
108
+ received = getattr(exc, "rcvd", None)
109
+ if received is not None and received.code in (4401, 4403):
110
+ return RemoteError(
111
+ f"server rejected the request: {received.reason or received.code}",
112
+ kind="authentication",
113
+ )
114
+ if received is not None and received.code == 4429:
115
+ return RemoteError(
116
+ f"server rejected the request: {received.reason or 'quota'}", kind="quota"
117
+ )
118
+ if received is not None and received.code in (4400, 1008):
119
+ return RemoteError(
120
+ f"server rejected the request: {received.reason}", kind="invalid_request"
121
+ )
122
+ return RemoteError(f"lost connection: {exc}", kind="connection", retryable=True)
123
+ if isinstance(exc, InvalidHandshake):
124
+ return RemoteError(f"handshake failed: {exc}", kind="connection", retryable=True)
125
+ if isinstance(exc, TimeoutError):
126
+ return RemoteError("the server stopped responding", kind="timeout", retryable=True)
127
+ if isinstance(exc, OSError):
128
+ return RemoteError(f"could not reach the server: {exc}", kind="network", retryable=True)
129
+ return RemoteError(f"{type(exc).__name__}: {exc}", kind="unknown")
130
+
131
+
132
+ _FATAL_CODES = {"unauthorized": "authentication", "quota": "quota", "protocol": "protocol"}
133
+
134
+
135
+ @dataclass
136
+ class _Attempt:
137
+ """State carried across reconnects within one exchange."""
138
+
139
+ working: list[dict[str, Any]]
140
+ results: dict[str, tuple[str, bool]] = field(default_factory=dict)
141
+ progressed: bool = False
142
+
143
+
144
+ class RemoteRuntime(TurnLoop):
145
+ """Same interface as :class:`traceforge.runtime.Runtime`, backed by a server."""
146
+
147
+ def __init__(
148
+ self,
149
+ workspace: Path,
150
+ settings: Settings,
151
+ mode: str = "agent",
152
+ approver: Approver | None = None,
153
+ ask_user: QuestionHandler | None = None,
154
+ store: SessionStore | None = None,
155
+ hooks: HookRunner | None = None,
156
+ ):
157
+ if not settings.server_url:
158
+ raise ValueError("server_url is not configured")
159
+ self.workspace = Path(workspace).resolve()
160
+ self.settings = settings
161
+ self.mode = mode
162
+ self.store = store or SessionStore()
163
+ self.hooks = hooks or HookRunner(self.workspace, settings.hooks)
164
+ approval = (
165
+ ApprovalMode.READ_ONLY if mode in {"ask", "review"} else ApprovalMode(settings.approval)
166
+ )
167
+ self.policy = ApprovalPolicy(approval, approver)
168
+ self.context = ToolContext(
169
+ self.workspace,
170
+ ask_user=ask_user,
171
+ read_only=mode in {"ask", "review", "plan"},
172
+ state={"fab": settings.fab},
173
+ )
174
+ self.registry = registry_for_mode(mode)
175
+ self.executor = HookedExecutor(
176
+ LocalExecutor(self.registry, self.context, self.policy), self.hooks
177
+ )
178
+ self.usage = Usage()
179
+ self._socket: Any = None
180
+
181
+ @property
182
+ def agent(self) -> RemoteRuntime: # `runtime.agent.usage` works for both runtimes
183
+ return self
184
+
185
+ async def cancel(self) -> None:
186
+ if self._socket is not None:
187
+ try:
188
+ await self._socket.send(json.dumps({"type": "cancel"}))
189
+ except Exception: # noqa: BLE001 - the socket may already be gone
190
+ pass
191
+
192
+ def _start_frame(self, messages: list[dict[str, Any]], content: list[dict[str, Any]]) -> dict:
193
+ frame: dict[str, Any] = {
194
+ "type": "turn",
195
+ "protocol": PROTOCOL_VERSION,
196
+ "mode": self.mode,
197
+ "effort": self.settings.effort,
198
+ "messages": messages,
199
+ "content": content,
200
+ "tools": self.executor.definitions(),
201
+ "parallel_safe": sorted(
202
+ name for name in self.registry.names() if self.executor.parallel_safe(name)
203
+ ),
204
+ "instructions": project_instructions(self.workspace, self.settings)[:20_000],
205
+ }
206
+ if self.settings.model and self.settings.model_explicit:
207
+ frame["model"] = self.settings.model
208
+ return frame
209
+
210
+ async def _exchange(
211
+ self, thread: Thread, content: list[dict[str, Any]], sink: EventSink | None
212
+ ) -> TurnResult:
213
+ try:
214
+ from websockets.asyncio.client import connect # noqa: F401
215
+ except ImportError as exc: # pragma: no cover
216
+ raise AgentError(
217
+ "hosted mode needs the 'remote' extra: pip install 'traceforge-pcb[remote]'"
218
+ ) from exc
219
+ self.executor.env = self.hook_env(thread)
220
+ attempt = _Attempt(list(thread.messages))
221
+ request_content = content
222
+ delays = backoff_schedule()
223
+ for number in range(1, MAX_ATTEMPTS + 1):
224
+ try:
225
+ return await self._attempt(thread, attempt, request_content, sink)
226
+ except asyncio.CancelledError:
227
+ await self.cancel()
228
+ self._save_partial(thread, attempt)
229
+ raise
230
+ except BaseException as exc: # noqa: BLE001 - classified below
231
+ if isinstance(exc, KeyboardInterrupt):
232
+ raise
233
+ error = classify(exc)
234
+ if not error.retryable or number == MAX_ATTEMPTS:
235
+ self._save_partial(thread, attempt)
236
+ raise error from exc
237
+ delay = delays[min(number - 1, len(delays) - 1)]
238
+ await _emit(
239
+ sink,
240
+ "notice",
241
+ {
242
+ "kind": "retry",
243
+ "attempt": number + 1,
244
+ "max_attempts": MAX_ATTEMPTS,
245
+ "delay": delay,
246
+ "error": error.kind,
247
+ "message": f"{error}; reconnecting in {delay:g}s "
248
+ f"(attempt {number + 1}/{MAX_ATTEMPTS})",
249
+ },
250
+ )
251
+ await asyncio.sleep(delay)
252
+ if attempt.progressed:
253
+ request_content = self._resume_point(attempt)
254
+ self._save_partial(thread, attempt, close=False)
255
+ raise AgentError("the server closed the connection before finishing the turn")
256
+
257
+ def _resume_point(self, attempt: _Attempt) -> list[dict[str, Any]]:
258
+ """Close the checkpointed history with known tool results; return the resume prompt."""
259
+ history = attempt.working
260
+ if history and history[-1]["role"] == "assistant":
261
+ pending = [
262
+ block
263
+ for block in history[-1]["content"]
264
+ if isinstance(block, dict) and block.get("type") == "tool_use"
265
+ ]
266
+ if pending:
267
+ results = []
268
+ for block in pending:
269
+ text, is_error = attempt.results.get(
270
+ block["id"], ("not run: the connection dropped before this tool ran", True)
271
+ )
272
+ result = {"type": "tool_result", "tool_use_id": block["id"], "content": text}
273
+ if is_error:
274
+ result["is_error"] = True
275
+ results.append(result)
276
+ history.append({"role": "user", "content": results})
277
+ return [{"type": "text", "text": RESUME_TEXT}]
278
+
279
+ def _save_partial(self, thread: Thread, attempt: _Attempt, *, close: bool = True) -> None:
280
+ if not attempt.progressed:
281
+ return
282
+ messages = [dict(m) for m in attempt.working]
283
+ if close:
284
+ _close_dangling_tool_uses(messages)
285
+ thread.messages = messages
286
+ if self.store is not None:
287
+ self.store.save(thread)
288
+
289
+ async def _attempt(
290
+ self,
291
+ thread: Thread,
292
+ attempt: _Attempt,
293
+ content: list[dict[str, Any]],
294
+ sink: EventSink | None,
295
+ ) -> TurnResult:
296
+ from websockets.asyncio.client import connect
297
+
298
+ assert self.settings.server_url
299
+ headers = {"Authorization": f"Bearer {self.settings.server_key or ''}"}
300
+ timeout = idle_timeout()
301
+ send_lock = asyncio.Lock()
302
+ tool_tasks: set[asyncio.Task] = set()
303
+ async with connect(
304
+ websocket_url(self.settings.server_url),
305
+ additional_headers=headers,
306
+ max_size=16 * 1024 * 1024,
307
+ open_timeout=30,
308
+ ) as socket:
309
+ self._socket = socket
310
+ try:
311
+ await socket.send(json.dumps(self._start_frame(attempt.working, content)))
312
+ while True:
313
+ raw = await asyncio.wait_for(socket.recv(), timeout)
314
+ message = json.loads(raw)
315
+ kind = message.get("type")
316
+ if kind == "event":
317
+ await _emit(sink, message.get("kind", "status"), message.get("data"))
318
+ elif kind == "checkpoint":
319
+ start = int(message.get("start") or 0)
320
+ new = message.get("messages") or []
321
+ if 0 <= start <= len(attempt.working):
322
+ attempt.working[start:] = new
323
+ attempt.progressed = True
324
+ thread.messages = [dict(m) for m in attempt.working]
325
+ if self.store is not None:
326
+ self.store.save(thread)
327
+ elif kind == "tool_request":
328
+ task = asyncio.create_task(
329
+ self._run_tool(message, socket, send_lock, attempt, sink)
330
+ )
331
+ tool_tasks.add(task)
332
+ task.add_done_callback(tool_tasks.discard)
333
+ elif kind == "done":
334
+ thread.messages = message.get("messages") or attempt.working
335
+ usage = Usage(
336
+ **{
337
+ k: int(v)
338
+ for k, v in (message.get("usage") or {}).items()
339
+ if k in Usage.__dataclass_fields__
340
+ }
341
+ )
342
+ for name in Usage.__dataclass_fields__:
343
+ setattr(
344
+ self.usage, name, getattr(self.usage, name) + getattr(usage, name)
345
+ )
346
+ thread.usage = self.usage.to_dict()
347
+ if self.store is not None:
348
+ self.store.save(thread)
349
+ await _emit(
350
+ sink,
351
+ "done",
352
+ {"stop_reason": message.get("stop_reason"), "usage": usage.to_dict()},
353
+ )
354
+ return TurnResult(
355
+ message.get("text", ""),
356
+ message.get("stop_reason"),
357
+ usage,
358
+ refusal=message.get("refusal"),
359
+ )
360
+ elif kind == "error":
361
+ code = str(message.get("code") or "")
362
+ raise RemoteError(
363
+ f"server: {message.get('message')} ({code})",
364
+ kind=_FATAL_CODES.get(code, "server"),
365
+ retryable=bool(message.get("retryable")),
366
+ )
367
+ finally:
368
+ self._socket = None
369
+ for task in tool_tasks:
370
+ task.cancel()
371
+
372
+ async def _run_tool(
373
+ self,
374
+ message: dict[str, Any],
375
+ socket: Any,
376
+ lock: asyncio.Lock,
377
+ attempt: _Attempt,
378
+ sink: EventSink | None,
379
+ ) -> None:
380
+ tool_id = str(message.get("id"))
381
+ name, arguments = message.get("name", ""), message.get("input") or {}
382
+ if tool_id in attempt.results: # a resumed server asking again: do not re-run
383
+ text, is_error = attempt.results[tool_id]
384
+ else:
385
+ await _emit(sink, "tool_call", {"id": tool_id, "name": name, "input": arguments})
386
+ text, is_error = await self.executor.execute(name, arguments, tool_id)
387
+ attempt.results[tool_id] = (text, is_error)
388
+ await _emit(
389
+ sink,
390
+ "tool_result",
391
+ {"id": tool_id, "name": name, "content": text, "is_error": is_error},
392
+ )
393
+ async with lock:
394
+ await socket.send(
395
+ json.dumps(
396
+ {"type": "tool_result", "id": tool_id, "content": text, "is_error": is_error}
397
+ )
398
+ )
399
+
400
+
401
+ # ------------------------------------------------------------------ account API
402
+
403
+
404
+ async def server_get(settings: Settings, path: str, timeout: float = 10.0) -> dict[str, Any]:
405
+ """GET a JSON endpoint of the configured server with the saved key."""
406
+ import httpx
407
+
408
+ if not settings.server_url:
409
+ raise AgentError("no hosted server configured; run `traceforge login`")
410
+ async with httpx.AsyncClient(timeout=timeout, trust_env=True) as client:
411
+ response = await client.get(
412
+ http_url(settings.server_url, path),
413
+ headers={"Authorization": f"Bearer {settings.server_key or ''}"},
414
+ )
415
+ if response.status_code in (401, 403):
416
+ raise RemoteError("the server rejected the API key", kind="authentication")
417
+ response.raise_for_status()
418
+ value = response.json()
419
+ return value if isinstance(value, dict) else {"value": value}
420
+
421
+
422
+ __all__ = ["RemoteRuntime", "AgentEvent", "classify", "server_get"]