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