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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- 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"]
|