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
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"]
|