wirestudio 0.10.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.
- wirestudio/__init__.py +3 -0
- wirestudio/agent/__init__.py +8 -0
- wirestudio/agent/agent.py +313 -0
- wirestudio/agent/session.py +65 -0
- wirestudio/agent/tools.py +518 -0
- wirestudio/api/__init__.py +3 -0
- wirestudio/api/__main__.py +45 -0
- wirestudio/api/app.py +884 -0
- wirestudio/api/schemas.py +236 -0
- wirestudio/api/serve.py +73 -0
- wirestudio/csp/__init__.py +20 -0
- wirestudio/csp/compatibility.py +414 -0
- wirestudio/csp/pin_solver.py +521 -0
- wirestudio/designs/__init__.py +11 -0
- wirestudio/designs/active.py +36 -0
- wirestudio/designs/events.py +108 -0
- wirestudio/designs/seed.py +200 -0
- wirestudio/designs/store.py +129 -0
- wirestudio/enclosure/__init__.py +30 -0
- wirestudio/enclosure/openscad.py +195 -0
- wirestudio/enclosure/search.py +217 -0
- wirestudio/examples/__init__.py +1 -0
- wirestudio/examples/attic-logger.json +82 -0
- wirestudio/examples/awning-control.json +158 -0
- wirestudio/examples/bl0906-mainmeter.json +67 -0
- wirestudio/examples/bluemotion.json +97 -0
- wirestudio/examples/bluesonoff.json +77 -0
- wirestudio/examples/desk-climate.json +69 -0
- wirestudio/examples/desk-matrix.json +72 -0
- wirestudio/examples/distance-sensor.json +119 -0
- wirestudio/examples/esp32-audio.json +124 -0
- wirestudio/examples/garage-motion.json +90 -0
- wirestudio/examples/keypad.json +86 -0
- wirestudio/examples/multi-temp.json +98 -0
- wirestudio/examples/nextion-thermostat.json +86 -0
- wirestudio/examples/oled.json +87 -0
- wirestudio/examples/parking-distance.json +75 -0
- wirestudio/examples/rc522.json +132 -0
- wirestudio/examples/room-climate.json +81 -0
- wirestudio/examples/rs485-energy.json +72 -0
- wirestudio/examples/securitypanel.json +151 -0
- wirestudio/examples/smart-plug-v1.json +84 -0
- wirestudio/examples/smart-plug.json +87 -0
- wirestudio/examples/ttgo-lora32.json +136 -0
- wirestudio/examples/tuya-smart-plug.json +97 -0
- wirestudio/examples/wasserpir.json +79 -0
- wirestudio/examples/weather-station.json +95 -0
- wirestudio/examples/wemosgps.json +101 -0
- wirestudio/fleet/__init__.py +4 -0
- wirestudio/fleet/client.py +266 -0
- wirestudio/generate/__init__.py +4 -0
- wirestudio/generate/__main__.py +48 -0
- wirestudio/generate/ascii_gen.py +114 -0
- wirestudio/generate/yaml_gen.py +321 -0
- wirestudio/kicad/__init__.py +5 -0
- wirestudio/kicad/generator.py +331 -0
- wirestudio/kicad/import.py +11 -0
- wirestudio/kicad/importer.py +232 -0
- wirestudio/kicad/symbol_parser.py +152 -0
- wirestudio/library/__init__.py +198 -0
- wirestudio/library/boards/esp01_1m.yaml +30 -0
- wirestudio/library/boards/esp32-c3-devkitm-1.yaml +53 -0
- wirestudio/library/boards/esp32-devkitc-v4.yaml +83 -0
- wirestudio/library/boards/esp32-s3-devkitc-1.yaml +77 -0
- wirestudio/library/boards/esp32-wrover-cam.yaml +71 -0
- wirestudio/library/boards/esp32cam-ai-thinker.yaml +78 -0
- wirestudio/library/boards/esp8285-1m.yaml +45 -0
- wirestudio/library/boards/m5stack-atom.yaml +49 -0
- wirestudio/library/boards/m5stack-atoms3.yaml +67 -0
- wirestudio/library/boards/nodemcu-32s.yaml +72 -0
- wirestudio/library/boards/nodemcu-v2.yaml +60 -0
- wirestudio/library/boards/ttgo-lora32-v1.yaml +91 -0
- wirestudio/library/boards/ttgo-t-beam.yaml +75 -0
- wirestudio/library/boards/wemos-d1-mini.yaml +67 -0
- wirestudio/library/components/adc.yaml +71 -0
- wirestudio/library/components/ads1115.yaml +61 -0
- wirestudio/library/components/ads1115_channel.yaml +69 -0
- wirestudio/library/components/aht10.yaml +63 -0
- wirestudio/library/components/apa102.yaml +66 -0
- wirestudio/library/components/bh1750.yaml +54 -0
- wirestudio/library/components/bl0906.yaml +97 -0
- wirestudio/library/components/bme280.yaml +51 -0
- wirestudio/library/components/bmp180.yaml +53 -0
- wirestudio/library/components/bmp280.yaml +61 -0
- wirestudio/library/components/cc1101.yaml +77 -0
- wirestudio/library/components/cse7766.yaml +58 -0
- wirestudio/library/components/dht.yaml +54 -0
- wirestudio/library/components/ds18b20.yaml +63 -0
- wirestudio/library/components/esp32_camera.yaml +103 -0
- wirestudio/library/components/esp32_rmt_led_strip.yaml +59 -0
- wirestudio/library/components/gpio_input.yaml +61 -0
- wirestudio/library/components/gpio_output.yaml +51 -0
- wirestudio/library/components/hc-sr04.yaml +55 -0
- wirestudio/library/components/hc-sr501.yaml +52 -0
- wirestudio/library/components/hlw8012.yaml +79 -0
- wirestudio/library/components/htu21d.yaml +53 -0
- wirestudio/library/components/hx711.yaml +60 -0
- wirestudio/library/components/ili9xxx.yaml +78 -0
- wirestudio/library/components/lcd_pcf8574.yaml +59 -0
- wirestudio/library/components/ld2420.yaml +57 -0
- wirestudio/library/components/max31855.yaml +58 -0
- wirestudio/library/components/max7219.yaml +62 -0
- wirestudio/library/components/max98357a.yaml +46 -0
- wirestudio/library/components/mcp23008.yaml +50 -0
- wirestudio/library/components/mcp23017.yaml +50 -0
- wirestudio/library/components/modbus.yaml +62 -0
- wirestudio/library/components/mpu6050.yaml +72 -0
- wirestudio/library/components/nextion.yaml +73 -0
- wirestudio/library/components/pcf8574.yaml +60 -0
- wirestudio/library/components/pulse_counter.yaml +70 -0
- wirestudio/library/components/rc522.yaml +51 -0
- wirestudio/library/components/rcwl-0516.yaml +49 -0
- wirestudio/library/components/rdm6300.yaml +43 -0
- wirestudio/library/components/rf_bridge.yaml +41 -0
- wirestudio/library/components/rotary_encoder.yaml +76 -0
- wirestudio/library/components/rtttl.yaml +52 -0
- wirestudio/library/components/sdm_meter.yaml +82 -0
- wirestudio/library/components/sht3xd.yaml +58 -0
- wirestudio/library/components/ssd1306.yaml +55 -0
- wirestudio/library/components/st7789.yaml +72 -0
- wirestudio/library/components/sx127x.yaml +90 -0
- wirestudio/library/components/tm1638.yaml +56 -0
- wirestudio/library/components/tsl2561.yaml +66 -0
- wirestudio/library/components/tuya.yaml +66 -0
- wirestudio/library/components/tuya_sensor.yaml +60 -0
- wirestudio/library/components/tuya_switch.yaml +53 -0
- wirestudio/library/components/uart_gps.yaml +41 -0
- wirestudio/library/components/vl53l0x.yaml +72 -0
- wirestudio/library/components/ws2812b.yaml +53 -0
- wirestudio/library/components/xpt2046.yaml +65 -0
- wirestudio/mcp/__init__.py +20 -0
- wirestudio/mcp/auth.py +114 -0
- wirestudio/mcp/server.py +528 -0
- wirestudio/model.py +153 -0
- wirestudio/recommend/__init__.py +8 -0
- wirestudio/recommend/recommender.py +223 -0
- wirestudio/schema/__init__.py +1 -0
- wirestudio/schema/design.schema.json +213 -0
- wirestudio/validate.py +26 -0
- wirestudio-0.10.0.dist-info/LICENSE +21 -0
- wirestudio-0.10.0.dist-info/METADATA +563 -0
- wirestudio-0.10.0.dist-info/RECORD +145 -0
- wirestudio-0.10.0.dist-info/WHEEL +5 -0
- wirestudio-0.10.0.dist-info/entry_points.txt +3 -0
- wirestudio-0.10.0.dist-info/top_level.txt +1 -0
wirestudio/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Claude tool-using agent for design.json edits.
|
|
2
|
+
|
|
3
|
+
The agent layer is intentionally small: tools mutate a per-turn working copy
|
|
4
|
+
of `design`, the manual agentic loop runs until the model emits `end_turn`,
|
|
5
|
+
and the updated design is returned alongside the assistant's text. Conversation
|
|
6
|
+
history persists in `sessions/<id>.jsonl`; the design itself is owned by the
|
|
7
|
+
client and arrives with each turn.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""The agentic loop: Claude + tool calls + design mutation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Iterator, Optional
|
|
9
|
+
|
|
10
|
+
from wirestudio.agent.session import SessionStore, FileSessionStore, new_session_id
|
|
11
|
+
from wirestudio.agent.tools import TOOL_SCHEMAS, execute_tool
|
|
12
|
+
from wirestudio.library import Library, default_library
|
|
13
|
+
|
|
14
|
+
# Guard the import so the module is still importable in environments that
|
|
15
|
+
# haven't installed `anthropic` yet (the API endpoint will then 503).
|
|
16
|
+
try:
|
|
17
|
+
import anthropic # noqa: F401
|
|
18
|
+
_ANTHROPIC_INSTALLED = True
|
|
19
|
+
except ImportError: # pragma: no cover - exercised in deployment, not tests
|
|
20
|
+
_ANTHROPIC_INSTALLED = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Default agent model. Sonnet handles tool-routing + design-dict editing
|
|
24
|
+
# fine and is ~5x cheaper than Opus. Override via WIRESTUDIO_AGENT_MODEL
|
|
25
|
+
# (e.g., set to claude-opus-4-7 for the long-tail ambiguous prompts where
|
|
26
|
+
# Opus's reasoning shows up). Per-call override still wins over the env.
|
|
27
|
+
DEFAULT_AGENT_MODEL = "claude-sonnet-4-6"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_model(explicit: Optional[str] = None) -> str:
|
|
31
|
+
return explicit or os.environ.get("WIRESTUDIO_AGENT_MODEL") or DEFAULT_AGENT_MODEL
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SYSTEM_INSTRUCTIONS = """\
|
|
35
|
+
You are a helper inside wirestudio, a tool that turns a `design.json` \
|
|
36
|
+
document into ESPHome YAML + an ASCII wiring diagram + a BOM.
|
|
37
|
+
|
|
38
|
+
You edit the user's current design via tools; the user already sees the \
|
|
39
|
+
rendered output update live. Be concise -- one or two sentences confirming \
|
|
40
|
+
what you changed is plenty. Do not paste the YAML back at them unless asked.
|
|
41
|
+
|
|
42
|
+
Conventions:
|
|
43
|
+
- Never invent a `library_id`. Use `search_components` (or `list_boards`) first.
|
|
44
|
+
- Prefer `add_component` over manually editing the design -- it auto-wires \
|
|
45
|
+
rails by voltage match and bus pins to a matching bus.
|
|
46
|
+
- After a non-trivial change, call `validate` once to make sure the design \
|
|
47
|
+
still renders. If it doesn't, fix the issue (commonly a missing bus or \
|
|
48
|
+
unset gpio pin) and re-validate.
|
|
49
|
+
- Pin assignments are the user's call. Don't try to swap pins to "free up" a \
|
|
50
|
+
GPIO unless the user asks.
|
|
51
|
+
- The user owns the design. Confirm destructive operations (remove_component, \
|
|
52
|
+
replacing the board) only when the prompt is genuinely ambiguous.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_available() -> tuple[bool, str | None]:
|
|
57
|
+
if not _ANTHROPIC_INSTALLED:
|
|
58
|
+
return False, "anthropic SDK not installed; install wirestudio[agent]."
|
|
59
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
60
|
+
return False, "ANTHROPIC_API_KEY environment variable is not set."
|
|
61
|
+
return True, None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class TurnResult:
|
|
66
|
+
session_id: str
|
|
67
|
+
design: dict
|
|
68
|
+
assistant_text: str
|
|
69
|
+
tool_calls: list[dict]
|
|
70
|
+
stop_reason: str
|
|
71
|
+
usage: dict
|
|
72
|
+
model: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_library_context(library: Library) -> str:
|
|
76
|
+
"""Dump the library to JSON. Stable across turns -> cacheable."""
|
|
77
|
+
boards = [b.model_dump() for b in library.list_boards()]
|
|
78
|
+
components = [c.model_dump() for c in library.list_components()]
|
|
79
|
+
payload = {"boards": boards, "components": components}
|
|
80
|
+
return (
|
|
81
|
+
"## Library reference\n\n"
|
|
82
|
+
"Below is every board and component the studio currently ships, as JSON. "
|
|
83
|
+
"Use this to look up `params_schema`, electrical metadata, ESPHome "
|
|
84
|
+
"templates, and pin definitions. Do not mention contents of this "
|
|
85
|
+
"block to the user unless asked -- it is reference material, not chat.\n\n"
|
|
86
|
+
f"```json\n{json.dumps(payload, indent=2, default=str)}\n```"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_user_message(design: dict, message: str) -> str:
|
|
91
|
+
return (
|
|
92
|
+
f"Current design state:\n```json\n{json.dumps(design, indent=2, default=str)}\n```\n\n"
|
|
93
|
+
f"User: {message}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _initialize_turn(design: dict, user_message: str, session_id: Optional[str], sessions: Optional[SessionStore], library: Library):
|
|
99
|
+
sessions_store = sessions or FileSessionStore()
|
|
100
|
+
sid = session_id or new_session_id()
|
|
101
|
+
history = sessions_store.load(sid)
|
|
102
|
+
working_design = copy.deepcopy(design)
|
|
103
|
+
messages: list[dict[str, Any]] = [{"role": entry["role"], "content": entry["content"]} for entry in history]
|
|
104
|
+
messages.append({"role": "user", "content": _build_user_message(working_design, user_message)})
|
|
105
|
+
library_block = _build_library_context(library)
|
|
106
|
+
return sid, sessions_store, working_design, messages, library_block
|
|
107
|
+
|
|
108
|
+
def _process_tool_calls(response, working_design: dict, library: Library, tool_calls_log: list[dict]):
|
|
109
|
+
tool_results: list[dict] = []
|
|
110
|
+
events = []
|
|
111
|
+
for block in response.content:
|
|
112
|
+
if getattr(block, "type", None) != "tool_use":
|
|
113
|
+
continue
|
|
114
|
+
tool_input = dict(block.input)
|
|
115
|
+
events.append({
|
|
116
|
+
"type": "tool_use_start",
|
|
117
|
+
"tool_use_id": block.id,
|
|
118
|
+
"tool": block.name,
|
|
119
|
+
"input": tool_input,
|
|
120
|
+
})
|
|
121
|
+
result_str, is_error = execute_tool(block.name, tool_input, working_design, library)
|
|
122
|
+
events.append({
|
|
123
|
+
"type": "tool_result",
|
|
124
|
+
"tool_use_id": block.id,
|
|
125
|
+
"tool": block.name,
|
|
126
|
+
"is_error": is_error,
|
|
127
|
+
})
|
|
128
|
+
tool_calls_log.append({
|
|
129
|
+
"tool": block.name,
|
|
130
|
+
"input": tool_input,
|
|
131
|
+
"is_error": is_error,
|
|
132
|
+
})
|
|
133
|
+
tool_results.append({
|
|
134
|
+
"type": "tool_result",
|
|
135
|
+
"tool_use_id": block.id,
|
|
136
|
+
"content": result_str,
|
|
137
|
+
"is_error": is_error,
|
|
138
|
+
})
|
|
139
|
+
return tool_results, events
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _serialize_assistant_block(block: object) -> dict:
|
|
143
|
+
"""Strip SDK-side parser metadata from a content block before feeding
|
|
144
|
+
it back as conversation history.
|
|
145
|
+
|
|
146
|
+
`Message.content[*].model_dump()` includes fields the SDK attaches
|
|
147
|
+
while parsing the streamed response (e.g. `parsed_output` on text
|
|
148
|
+
blocks under structured-output paths). The Anthropic API rejects
|
|
149
|
+
those extras on input -- a multi-turn agent loop fails on the next
|
|
150
|
+
request with `messages.N.content.M.text.parsed_output: Extra inputs
|
|
151
|
+
are not permitted`. So we hand-pick only the fields the API
|
|
152
|
+
documents as input-valid for each block type.
|
|
153
|
+
"""
|
|
154
|
+
btype = getattr(block, "type", None)
|
|
155
|
+
if btype == "text":
|
|
156
|
+
return {"type": "text", "text": getattr(block, "text", "")}
|
|
157
|
+
if btype == "tool_use":
|
|
158
|
+
return {
|
|
159
|
+
"type": "tool_use",
|
|
160
|
+
"id": getattr(block, "id", None),
|
|
161
|
+
"name": getattr(block, "name", None),
|
|
162
|
+
"input": getattr(block, "input", {}),
|
|
163
|
+
}
|
|
164
|
+
# Defensive: future block kinds (e.g. thinking, server_tool_use) get
|
|
165
|
+
# dumped as-is. Add explicit branches when we start using them.
|
|
166
|
+
return block.model_dump() if hasattr(block, "model_dump") else dict(block) # type: ignore[arg-type]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def stream_turn_events(
|
|
170
|
+
*,
|
|
171
|
+
design: dict,
|
|
172
|
+
user_message: str,
|
|
173
|
+
session_id: Optional[str] = None,
|
|
174
|
+
library: Optional[Library] = None,
|
|
175
|
+
sessions: Optional[SessionStore] = None,
|
|
176
|
+
model: Optional[str] = None,
|
|
177
|
+
max_iterations: int = 12,
|
|
178
|
+
) -> Iterator[dict]:
|
|
179
|
+
"""Run a single user turn and yield events as they happen.
|
|
180
|
+
|
|
181
|
+
`model` defaults to `WIRESTUDIO_AGENT_MODEL` env var, falling back to
|
|
182
|
+
`DEFAULT_AGENT_MODEL` (Sonnet). Pass an explicit string to override
|
|
183
|
+
per-call (e.g., the agent UI could expose a model picker)."""
|
|
184
|
+
available, reason = is_available()
|
|
185
|
+
if not available:
|
|
186
|
+
yield {"type": "error", "message": reason or "agent unavailable"}
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
library_instance = library or default_library()
|
|
190
|
+
resolved_model = _resolve_model(model)
|
|
191
|
+
|
|
192
|
+
sid, sessions_store, working_design, messages, library_block = _initialize_turn(
|
|
193
|
+
design, user_message, session_id, sessions, library_instance
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
yield {"type": "session_start", "session_id": sid}
|
|
197
|
+
|
|
198
|
+
import anthropic
|
|
199
|
+
client = anthropic.Anthropic()
|
|
200
|
+
|
|
201
|
+
tool_calls_log: list[dict] = []
|
|
202
|
+
accumulated_usage = {"input_tokens": 0, "output_tokens": 0,
|
|
203
|
+
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}
|
|
204
|
+
|
|
205
|
+
final_text_pieces: list[str] = []
|
|
206
|
+
stop_reason = ""
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
for _ in range(max_iterations):
|
|
210
|
+
with client.messages.stream(
|
|
211
|
+
model=resolved_model,
|
|
212
|
+
max_tokens=4096,
|
|
213
|
+
thinking={"type": "adaptive"},
|
|
214
|
+
# Two cache breakpoints: SYSTEM_INSTRUCTIONS is small but stable
|
|
215
|
+
# across every turn -> always a hit after the first, ~90% read
|
|
216
|
+
# discount. library_block is ~30-50KB stable JSON -> the big
|
|
217
|
+
# win. Anthropic caches at each breakpoint that has cache_control.
|
|
218
|
+
system=[
|
|
219
|
+
{"type": "text", "text": SYSTEM_INSTRUCTIONS, "cache_control": {"type": "ephemeral"}},
|
|
220
|
+
{"type": "text", "text": library_block, "cache_control": {"type": "ephemeral"}},
|
|
221
|
+
],
|
|
222
|
+
tools=TOOL_SCHEMAS,
|
|
223
|
+
messages=messages,
|
|
224
|
+
) as stream:
|
|
225
|
+
for event in stream:
|
|
226
|
+
etype = getattr(event, "type", None)
|
|
227
|
+
if etype == "content_block_delta":
|
|
228
|
+
delta = getattr(event, "delta", None)
|
|
229
|
+
if delta is not None and getattr(delta, "type", None) == "text_delta":
|
|
230
|
+
yield {"type": "text_delta", "text": delta.text}
|
|
231
|
+
response = stream.get_final_message()
|
|
232
|
+
|
|
233
|
+
for k in accumulated_usage:
|
|
234
|
+
accumulated_usage[k] += getattr(response.usage, k, 0) or 0
|
|
235
|
+
|
|
236
|
+
stop_reason = response.stop_reason or ""
|
|
237
|
+
|
|
238
|
+
text_pieces = [b.text for b in response.content if getattr(b, "type", None) == "text"]
|
|
239
|
+
if text_pieces:
|
|
240
|
+
final_text_pieces = text_pieces
|
|
241
|
+
|
|
242
|
+
if response.stop_reason != "tool_use":
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
messages.append({"role": "assistant", "content": [_serialize_assistant_block(b) for b in response.content]})
|
|
246
|
+
tool_results, tool_events = _process_tool_calls(response, working_design, library_instance, tool_calls_log)
|
|
247
|
+
for event in tool_events:
|
|
248
|
+
yield event
|
|
249
|
+
messages.append({"role": "user", "content": tool_results})
|
|
250
|
+
else:
|
|
251
|
+
if not final_text_pieces:
|
|
252
|
+
final_text_pieces = ["(agent exceeded max iterations without finishing the turn)"]
|
|
253
|
+
except anthropic.APIError as e:
|
|
254
|
+
yield {"type": "error", "message": f"agent API call failed: {e}"}
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
final_text = "\n\n".join(final_text_pieces).strip() if final_text_pieces else ""
|
|
258
|
+
|
|
259
|
+
sessions_store.append(sid, "user", user_message)
|
|
260
|
+
sessions_store.append(sid, "assistant", final_text or "(no reply)")
|
|
261
|
+
|
|
262
|
+
yield {
|
|
263
|
+
"type": "turn_complete",
|
|
264
|
+
"session_id": sid,
|
|
265
|
+
"design": working_design,
|
|
266
|
+
"assistant_text": final_text,
|
|
267
|
+
"tool_calls": tool_calls_log,
|
|
268
|
+
"stop_reason": stop_reason,
|
|
269
|
+
"usage": accumulated_usage,
|
|
270
|
+
"model": resolved_model,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def run_turn(
|
|
276
|
+
*,
|
|
277
|
+
design: dict,
|
|
278
|
+
user_message: str,
|
|
279
|
+
session_id: Optional[str] = None,
|
|
280
|
+
library: Optional[Library] = None,
|
|
281
|
+
sessions: Optional[SessionStore] = None,
|
|
282
|
+
model: Optional[str] = None,
|
|
283
|
+
max_iterations: int = 12,
|
|
284
|
+
) -> TurnResult:
|
|
285
|
+
"""Non-streaming wrapper. Consumes stream_turn_events and collapses
|
|
286
|
+
its events into a TurnResult so existing callers don't change."""
|
|
287
|
+
final: dict | None = None
|
|
288
|
+
error_msg: str | None = None
|
|
289
|
+
for event in stream_turn_events(
|
|
290
|
+
design=design,
|
|
291
|
+
user_message=user_message,
|
|
292
|
+
session_id=session_id,
|
|
293
|
+
library=library,
|
|
294
|
+
sessions=sessions,
|
|
295
|
+
model=model,
|
|
296
|
+
max_iterations=max_iterations,
|
|
297
|
+
):
|
|
298
|
+
if event["type"] == "turn_complete":
|
|
299
|
+
final = event
|
|
300
|
+
elif event["type"] == "error":
|
|
301
|
+
error_msg = event["message"]
|
|
302
|
+
|
|
303
|
+
if final is None:
|
|
304
|
+
raise RuntimeError(error_msg or "agent turn did not complete")
|
|
305
|
+
return TurnResult(
|
|
306
|
+
session_id=final["session_id"],
|
|
307
|
+
design=final["design"],
|
|
308
|
+
assistant_text=final["assistant_text"],
|
|
309
|
+
tool_calls=final["tool_calls"],
|
|
310
|
+
stop_reason=final["stop_reason"],
|
|
311
|
+
usage=final["usage"],
|
|
312
|
+
model=final.get("model", ""),
|
|
313
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Append-only conversation history at sessions/<id>.jsonl.
|
|
2
|
+
|
|
3
|
+
Stores plain {role, content, timestamp} entries -- the within-turn
|
|
4
|
+
tool_use / tool_result ceremony stays in memory. The design itself is
|
|
5
|
+
*never* persisted in a session; the client owns it and ships it with
|
|
6
|
+
every turn.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, Protocol
|
|
15
|
+
|
|
16
|
+
SESSIONS_DIR_ENV_DEFAULT = Path(__file__).resolve().parent.parent.parent / "sessions"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_session_id() -> str:
|
|
20
|
+
return secrets.token_urlsafe(8)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SessionStore(Protocol):
|
|
24
|
+
def exists(self, session_id: str) -> bool: ...
|
|
25
|
+
def load(self, session_id: str) -> list[dict]: ...
|
|
26
|
+
def append(self, session_id: str, role: str, content: str) -> dict: ...
|
|
27
|
+
|
|
28
|
+
class FileSessionStore(SessionStore):
|
|
29
|
+
|
|
30
|
+
"""One-line-per-message JSONL files. Cheap, greppable, agent-friendly."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, root: Optional[Path] = None) -> None:
|
|
33
|
+
self.root = Path(root) if root else SESSIONS_DIR_ENV_DEFAULT
|
|
34
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
def path(self, session_id: str) -> Path:
|
|
37
|
+
if not session_id or "/" in session_id or ".." in session_id:
|
|
38
|
+
raise ValueError(f"invalid session_id: {session_id!r}")
|
|
39
|
+
return self.root / f"{session_id}.jsonl"
|
|
40
|
+
|
|
41
|
+
def exists(self, session_id: str) -> bool:
|
|
42
|
+
return self.path(session_id).exists()
|
|
43
|
+
|
|
44
|
+
def load(self, session_id: str) -> list[dict]:
|
|
45
|
+
path = self.path(session_id)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return []
|
|
48
|
+
out: list[dict] = []
|
|
49
|
+
with path.open() as f:
|
|
50
|
+
for line in f:
|
|
51
|
+
line = line.strip()
|
|
52
|
+
if not line:
|
|
53
|
+
continue
|
|
54
|
+
out.append(json.loads(line))
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
def append(self, session_id: str, role: str, content: str) -> dict:
|
|
58
|
+
entry = {
|
|
59
|
+
"role": role,
|
|
60
|
+
"content": content,
|
|
61
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
62
|
+
}
|
|
63
|
+
with self.path(session_id).open("a") as f:
|
|
64
|
+
f.write(json.dumps(entry) + "\n")
|
|
65
|
+
return entry
|