spatialagents 0.1.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.
- spatialagents/__init__.py +66 -0
- spatialagents/approvals.py +137 -0
- spatialagents/backend.py +181 -0
- spatialagents/cache_plan.py +117 -0
- spatialagents/chat/__init__.py +11 -0
- spatialagents/chat/cli.py +84 -0
- spatialagents/chat/http.py +308 -0
- spatialagents/chat/server.py +269 -0
- spatialagents/chat/static/chat.css +219 -0
- spatialagents/chat/static/chat.js +933 -0
- spatialagents/chat/static/index.html +16 -0
- spatialagents/conversations.py +292 -0
- spatialagents/errors.py +73 -0
- spatialagents/gateway.py +192 -0
- spatialagents/offload.py +92 -0
- spatialagents/orchestrator.py +259 -0
- spatialagents/plans.py +800 -0
- spatialagents/policy.py +118 -0
- spatialagents/prefix.py +79 -0
- spatialagents/py.typed +0 -0
- spatialagents/runner.py +813 -0
- spatialagents/schema.py +167 -0
- spatialagents/serialize.py +88 -0
- spatialagents/sessions.py +276 -0
- spatialagents/testing.py +927 -0
- spatialagents/toolcache.py +144 -0
- spatialagents/tools.py +460 -0
- spatialagents/trace.py +237 -0
- spatialagents/wire.py +194 -0
- spatialagents-0.1.0.dist-info/METADATA +55 -0
- spatialagents-0.1.0.dist-info/RECORD +33 -0
- spatialagents-0.1.0.dist-info/WHEEL +4 -0
- spatialagents-0.1.0.dist-info/entry_points.txt +2 -0
spatialagents/plans.py
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
"""Plans: from a question to a report, with the model asked twice.
|
|
2
|
+
|
|
3
|
+
A chat loop asks the model at every step. A plan asks it twice -- once to
|
|
4
|
+
**compose** a plan for the goal from what the dataset can support, once to
|
|
5
|
+
**summarise** what the finished run found -- and everything in between is the
|
|
6
|
+
product's deterministic workflow layer: validation, the questions only the
|
|
7
|
+
scientist can answer (decisions), the run, step by step. That is the shape of
|
|
8
|
+
Plexora's decision loop (each model call one structured packet from a cached
|
|
9
|
+
prefix, validated locally, repaired once), applied to a product whose unit of
|
|
10
|
+
work is a deterministic function call.
|
|
11
|
+
|
|
12
|
+
run = PlanRun(host, gateway, system=prefix, root=root)
|
|
13
|
+
run.compose("compare neighbourhoods between responders and non-responders", "cohort")
|
|
14
|
+
run.answer("d-groupBy", "response") # the person's answer, never the model's
|
|
15
|
+
run.confirm_and_run() # runs until done, a decision, a failure
|
|
16
|
+
run.summarise() # the report, every sentence checked
|
|
17
|
+
|
|
18
|
+
**What the product supplies** is a `PlanHost`: the two packets (each `{task,
|
|
19
|
+
input, schema}`), what is accepted from each answer, the plan's view, the
|
|
20
|
+
person's answers and the run. SCIMAP Pro's is `scimappro.ai._harness.plans`
|
|
21
|
+
over its workflow layer; `testing.FakePlanHost` is the reference. The host
|
|
22
|
+
decides everything scientific -- which functions exist, what may run on which
|
|
23
|
+
unit with how many replicates, what a sentence may claim -- and this module
|
|
24
|
+
decides nothing of the kind: it carries packets to the model and answers back.
|
|
25
|
+
|
|
26
|
+
**Packets.** Each is one call: the host's cached prefix as the system prompt,
|
|
27
|
+
then one user turn -- the packet's input as canonical JSON and the rule
|
|
28
|
+
"answer with one JSON object matching the answer schema" -- with the schema
|
|
29
|
+
reduced to what providers' structured outputs accept (`schema.structured`) and
|
|
30
|
+
the answer read back through `schema.decode`. A bad answer (not JSON, or
|
|
31
|
+
problems the host can name) gets ONE repair: the same conversation, the
|
|
32
|
+
previous answer, and the problems. A compose repair replaces the draft under
|
|
33
|
+
the same plan id (`accept_composed(..., plan_id=)`). A summary is always
|
|
34
|
+
settled, problems or not: the host downgrades what does not hold. A model's bad
|
|
35
|
+
JSON never raises; after its repair the run is `failed` with the reason.
|
|
36
|
+
|
|
37
|
+
**Decisions are the person's.** `answer` records what the person said through
|
|
38
|
+
the host; no model output ever answers a decision (`resolve` only suggests
|
|
39
|
+
which option a free-text answer meant, for the person to confirm).
|
|
40
|
+
|
|
41
|
+
**Phases**, persisted in `<root>/<run_id>.json` with atomic writes so
|
|
42
|
+
`PlanRun.resume` continues after a restart:
|
|
43
|
+
|
|
44
|
+
composing a compose packet is out (or paused for credit)
|
|
45
|
+
awaiting_decision the plan waits on the person: open decisions, or a checkpoint to continue past
|
|
46
|
+
validated ready; the person confirms it to run
|
|
47
|
+
running the host is running steps
|
|
48
|
+
finished the run is done; the summary is next
|
|
49
|
+
summarising a summary packet is out (or paused for credit)
|
|
50
|
+
reported the report is written
|
|
51
|
+
nothing the goal composed no plan; `summary` says what would answer it
|
|
52
|
+
failed `reason` says why
|
|
53
|
+
stopped the plan was stopped (an answer, a person, an until_step)
|
|
54
|
+
|
|
55
|
+
A refusal the gateway pauses on (`gateway.PAUSE_CODES`: credits, caps, a
|
|
56
|
+
provider still down) leaves the phase as it was with `paused` set; `retry()`
|
|
57
|
+
sends the packet again. Events -- composed, problems, decision_needed, step,
|
|
58
|
+
run_finished, report, usage, paused, failed, phase -- go to `on_event` and are
|
|
59
|
+
numbered into `<root>/<run_id>.events.jsonl`, which a page replays.
|
|
60
|
+
`PlanService` drives runs on threads for an HTTP host, one operation per run
|
|
61
|
+
at a time.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import json
|
|
67
|
+
import threading
|
|
68
|
+
import time
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from typing import Protocol
|
|
71
|
+
|
|
72
|
+
from spatialagents import schema as _schema
|
|
73
|
+
from spatialagents.errors import AgentError
|
|
74
|
+
from spatialagents.gateway import PAUSE_CODES, GatewayError
|
|
75
|
+
from spatialagents.sessions import atomic_write, new_session_id
|
|
76
|
+
from spatialagents.trace import NullTrace
|
|
77
|
+
from spatialagents.wire import ModelRequest, canonical, text_block, with_breakpoints
|
|
78
|
+
|
|
79
|
+
__all__ = ["CALLING", "PHASES", "PlanHost", "PlanRun", "PlanService", "phase_of"]
|
|
80
|
+
|
|
81
|
+
TEXT_CAPABILITY = "text_reasoning"
|
|
82
|
+
MAX_TOKENS = 16_000
|
|
83
|
+
PHASES = ("composing", "awaiting_decision", "validated", "running", "finished", "summarising",
|
|
84
|
+
"reported", "nothing", "failed", "stopped")
|
|
85
|
+
#: Phases in which a model call is out, or would be on `retry()`.
|
|
86
|
+
CALLING = ("composing", "summarising")
|
|
87
|
+
ANSWER_RULE = "Answer with ONE JSON object that matches the answer schema, and nothing else."
|
|
88
|
+
#: How many times `confirm_and_run` asks the host to go on while it reports progress.
|
|
89
|
+
MAX_RUN_CALLS = 200
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PlanHost(Protocol):
|
|
93
|
+
"""What a product gives the plan runner. Every method is deterministic."""
|
|
94
|
+
|
|
95
|
+
def compose_packet(self, goal: str, dataset: str) -> dict:
|
|
96
|
+
"""`{"task": "plan.compose", "input": dict, "schema": JSON schema}`."""
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
def accept_composed(self, goal: str, dataset: str, answer: dict, *, plan_id: str | None = None
|
|
100
|
+
) -> tuple[dict | None, list[dict]]:
|
|
101
|
+
"""`(plan view or None, problems the composer can fix)`. None: the
|
|
102
|
+
answer composed nothing (`answer["summary"]` says what would answer
|
|
103
|
+
the goal). With `plan_id` the answer replaces that draft."""
|
|
104
|
+
raise NotImplementedError
|
|
105
|
+
|
|
106
|
+
def plan(self, plan_id: str) -> dict:
|
|
107
|
+
"""The plan's view: id, version, status, steps[], decisions[], next,
|
|
108
|
+
claim_ceiling, unit_of_replication, n_per_arm, validation."""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
def answer(self, plan_id: str, decision_id: str, value) -> dict:
|
|
112
|
+
"""Record the person's answer to a decision; returns the view."""
|
|
113
|
+
raise NotImplementedError
|
|
114
|
+
|
|
115
|
+
def run(self, plan_id: str, *, confirm: bool = False, from_step=None, until_step=None) -> dict:
|
|
116
|
+
"""Run the plan's pending steps (confirming it first when `confirm`);
|
|
117
|
+
returns the view and `ran`, the steps this call ran."""
|
|
118
|
+
raise NotImplementedError
|
|
119
|
+
|
|
120
|
+
def summarise_packet(self, plan_id: str) -> dict:
|
|
121
|
+
"""`{"task": "plan.summarise", "input": dict, "schema": JSON schema}`."""
|
|
122
|
+
raise NotImplementedError
|
|
123
|
+
|
|
124
|
+
def accept_summary(self, plan_id: str, answer: dict) -> list[dict]:
|
|
125
|
+
"""What is wrong with a summary's sentences, for one repair."""
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
def settle_summary(self, plan_id: str, answer: dict) -> dict:
|
|
129
|
+
"""The report as it will be shown: `{"sentences", "markdown", "path"}`."""
|
|
130
|
+
raise NotImplementedError
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _Paused(Exception):
|
|
134
|
+
def __init__(self, exc: GatewayError):
|
|
135
|
+
super().__init__(str(exc))
|
|
136
|
+
self.exc = exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Failed(Exception):
|
|
140
|
+
"""A packet could not be completed; the reason is said, never raised past the run."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def phase_of(view: dict | None, *, confirmed: bool = False) -> str:
|
|
144
|
+
"""The run's phase for a plan in this state."""
|
|
145
|
+
if not view:
|
|
146
|
+
return "nothing"
|
|
147
|
+
status = view.get("status")
|
|
148
|
+
open_ = [d for d in view.get("decisions") or () if d.get("state") == "open"]
|
|
149
|
+
if status == "draft":
|
|
150
|
+
return "awaiting_decision" if open_ else "failed"
|
|
151
|
+
if status == "validated":
|
|
152
|
+
return "awaiting_decision" if any(d.get("blocking", True) for d in open_) else "validated"
|
|
153
|
+
if status in ("confirmed", "running"):
|
|
154
|
+
return "running"
|
|
155
|
+
if status == "awaiting_decision":
|
|
156
|
+
return "awaiting_decision"
|
|
157
|
+
if status == "done":
|
|
158
|
+
return "finished"
|
|
159
|
+
if status in ("failed", "stopped"):
|
|
160
|
+
return status
|
|
161
|
+
return "awaiting_decision" if open_ else ("running" if confirmed else "validated")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _problems_text(problems: list) -> str:
|
|
165
|
+
lines = []
|
|
166
|
+
for item in problems:
|
|
167
|
+
where = f"step {item['step']}: " if isinstance(item, dict) and item.get("step") else ""
|
|
168
|
+
message = item.get("message") if isinstance(item, dict) else str(item)
|
|
169
|
+
lines.append(f"- {where}{message}")
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _failure_of(view: dict) -> str:
|
|
174
|
+
for step in view.get("steps") or ():
|
|
175
|
+
if step.get("status") == "failed":
|
|
176
|
+
error = step.get("error") or {}
|
|
177
|
+
message = error.get("message") if isinstance(error, dict) else str(error)
|
|
178
|
+
return f"step {step.get('id')} ({step.get('function')}) failed: {message}"
|
|
179
|
+
problems = [p.get("message") for p in (view.get("validation") or {}).get("problems") or ()
|
|
180
|
+
if isinstance(p, dict) and p.get("blocking")]
|
|
181
|
+
if problems:
|
|
182
|
+
return "the plan cannot run: " + "; ".join(str(p) for p in problems[:5])
|
|
183
|
+
return "the plan cannot run"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class PlanRun:
|
|
187
|
+
"""One goal's plan, from compose to report, for one host and one gateway."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, host, gateway, *, system: list, root, run_id: str | None = None, on_event=None,
|
|
190
|
+
max_repairs: int = 1, capability: str = TEXT_CAPABILITY, max_tokens: int = MAX_TOKENS,
|
|
191
|
+
feature: str = "plan", model: str | None = None, trace=None, state: dict | None = None):
|
|
192
|
+
self.host = host
|
|
193
|
+
self.gateway = gateway
|
|
194
|
+
self.system = list(system or [])
|
|
195
|
+
self.root = Path(root)
|
|
196
|
+
self.on_event = on_event
|
|
197
|
+
self.max_repairs = max(0, int(max_repairs))
|
|
198
|
+
self.capability = capability
|
|
199
|
+
self.max_tokens = max_tokens
|
|
200
|
+
self.feature = feature
|
|
201
|
+
self.model = model
|
|
202
|
+
self.trace = trace or NullTrace()
|
|
203
|
+
self._lock = threading.RLock()
|
|
204
|
+
self._stop_requested = False
|
|
205
|
+
self.state = state or {
|
|
206
|
+
"run_id": run_id or new_session_id("pr"), "phase": "composing", "goal": None, "dataset": None,
|
|
207
|
+
"plan_id": None, "confirmed": False, "summary": None, "reason": None, "paused": None,
|
|
208
|
+
"packets": [], "usage": {}, "charged_micro": 0, "report": None, "events": 0, "calls": 0,
|
|
209
|
+
"created_at": time.time()}
|
|
210
|
+
if state is None:
|
|
211
|
+
self.trace.start_run(self.run_id, "plan", session_id=self.run_id, capability=capability,
|
|
212
|
+
billing="dev" if getattr(gateway, "dev", False) else "credits")
|
|
213
|
+
|
|
214
|
+
# -- identity and persistence --------------------------------------------------
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def run_id(self) -> str:
|
|
218
|
+
return self.state["run_id"]
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def phase(self) -> str:
|
|
222
|
+
return self.state["phase"]
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def plan_id(self) -> str | None:
|
|
226
|
+
return self.state["plan_id"]
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def path(self) -> Path:
|
|
230
|
+
return self.root / f"{self.run_id}.json"
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def events_path(self) -> Path:
|
|
234
|
+
return self.root / f"{self.run_id}.events.jsonl"
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def resume(cls, root, run_id: str, host, gateway, **options) -> "PlanRun":
|
|
238
|
+
"""The run as it was saved: its phase, plan, packets and usage."""
|
|
239
|
+
path = Path(root) / f"{run_id}.json"
|
|
240
|
+
if not str(run_id).replace("_", "").replace("-", "").isalnum() or not path.is_file():
|
|
241
|
+
raise AgentError("invalid_input", f"no plan run {run_id!r}")
|
|
242
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
243
|
+
return cls(host, gateway, root=root, state=state, **options)
|
|
244
|
+
|
|
245
|
+
def _save(self) -> None:
|
|
246
|
+
with self._lock:
|
|
247
|
+
self.state["updated_at"] = time.time()
|
|
248
|
+
atomic_write(self.path, json.dumps(self.state, default=str, ensure_ascii=False))
|
|
249
|
+
|
|
250
|
+
def _update(self, **changes) -> None:
|
|
251
|
+
with self._lock:
|
|
252
|
+
before = self.state.get("phase")
|
|
253
|
+
self.state.update(changes)
|
|
254
|
+
self._save()
|
|
255
|
+
if changes.get("phase") and changes["phase"] != before:
|
|
256
|
+
self._emit("phase", phase=changes["phase"], reason=self.state.get("reason"))
|
|
257
|
+
|
|
258
|
+
def _emit(self, kind: str, **fields) -> dict:
|
|
259
|
+
with self._lock:
|
|
260
|
+
self.state["events"] = int(self.state.get("events") or 0) + 1
|
|
261
|
+
event = {"seq": self.state["events"], "at": time.time(), "event": kind, "run_id": self.run_id,
|
|
262
|
+
**fields}
|
|
263
|
+
self.events_path.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
with open(self.events_path, "a", encoding="utf-8") as handle:
|
|
265
|
+
handle.write(json.dumps(event, default=str, ensure_ascii=False) + "\n")
|
|
266
|
+
atomic_write(self.path, json.dumps(self.state, default=str, ensure_ascii=False))
|
|
267
|
+
if self.on_event is not None:
|
|
268
|
+
self.on_event(event)
|
|
269
|
+
return event
|
|
270
|
+
|
|
271
|
+
def events(self, after: int = 0) -> list:
|
|
272
|
+
"""The run's events with `seq > after`, oldest first."""
|
|
273
|
+
if not self.events_path.is_file():
|
|
274
|
+
return []
|
|
275
|
+
out = []
|
|
276
|
+
for line in self.events_path.read_text(encoding="utf-8").splitlines():
|
|
277
|
+
try:
|
|
278
|
+
event = json.loads(line)
|
|
279
|
+
except ValueError:
|
|
280
|
+
continue
|
|
281
|
+
if int(event.get("seq") or 0) > after:
|
|
282
|
+
out.append(event)
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
# -- what a caller is shown ----------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def view(self) -> dict | None:
|
|
288
|
+
return self.host.plan(self.plan_id) if self.plan_id else None
|
|
289
|
+
|
|
290
|
+
def actions(self, view: dict | None = None) -> list:
|
|
291
|
+
"""What the person can do now: answer, confirm, continue, summarise, retry, compose."""
|
|
292
|
+
phase = self.phase
|
|
293
|
+
if self.state.get("paused"):
|
|
294
|
+
return ["retry"]
|
|
295
|
+
if phase == "awaiting_decision":
|
|
296
|
+
view = view if view is not None else self.view()
|
|
297
|
+
open_ = [d for d in (view or {}).get("decisions") or () if d.get("state") == "open"]
|
|
298
|
+
if open_:
|
|
299
|
+
return ["answer"]
|
|
300
|
+
return ["continue"] if self.state.get("confirmed") else ["confirm"]
|
|
301
|
+
if phase == "validated":
|
|
302
|
+
return ["confirm"]
|
|
303
|
+
if phase == "finished":
|
|
304
|
+
return ["summarise"]
|
|
305
|
+
if phase == "stopped" and self.state.get("confirmed"):
|
|
306
|
+
return ["continue"]
|
|
307
|
+
if phase in ("nothing", "failed"):
|
|
308
|
+
return ["compose"]
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
def status(self) -> dict:
|
|
312
|
+
view = self.view() if self.plan_id else None
|
|
313
|
+
keep = ("run_id", "phase", "goal", "dataset", "plan_id", "confirmed", "summary", "reason", "paused",
|
|
314
|
+
"usage", "charged_micro", "report", "created_at", "updated_at")
|
|
315
|
+
out = {key: self.state.get(key) for key in keep}
|
|
316
|
+
out["charged_credits"] = round(int(self.state.get("charged_micro") or 0) / 10_000, 2)
|
|
317
|
+
out["packets"] = [{k: p.get(k) for k in ("kind", "task", "attempts", "repaired", "ok", "problems",
|
|
318
|
+
"gateway_request_id", "charged_micro")}
|
|
319
|
+
for p in self.state.get("packets") or ()]
|
|
320
|
+
out["plan"] = view
|
|
321
|
+
out["actions"] = self.actions(view)
|
|
322
|
+
return out
|
|
323
|
+
|
|
324
|
+
# -- the model -----------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
def _call(self, kind: str, packet: dict, messages: list):
|
|
327
|
+
"""One packet call: `(answer or None, text, problem or None)`. Raises
|
|
328
|
+
`_Paused` on a refusal the run waits out and `_Failed` on any other."""
|
|
329
|
+
with self._lock:
|
|
330
|
+
# Every call gets a key of its own, a refused one included: the
|
|
331
|
+
# gateway frees a key only for what never reached a provider.
|
|
332
|
+
number = self.state["calls"] = int(self.state.get("calls") or 0) + 1
|
|
333
|
+
self._save()
|
|
334
|
+
source = packet.get("schema") or {}
|
|
335
|
+
context = {"feature": self.feature, "agent": "plan", "workflow": "plan", "session_id": self.run_id,
|
|
336
|
+
"attempt": 1}
|
|
337
|
+
request = ModelRequest(capability=self.capability, system=self.system,
|
|
338
|
+
messages=with_breakpoints(messages), max_tokens=self.max_tokens,
|
|
339
|
+
output_schema=_schema.structured(source) if source else None,
|
|
340
|
+
context=context, model=self.model, task=packet.get("task"))
|
|
341
|
+
try:
|
|
342
|
+
response = self.gateway.messages(request, idempotency_key=f"{self.run_id}.{kind}.{number}")
|
|
343
|
+
except GatewayError as exc:
|
|
344
|
+
if exc.code in PAUSE_CODES:
|
|
345
|
+
raise _Paused(exc) from None
|
|
346
|
+
raise _Failed(f"the model call was refused: {exc.code}: {exc}") from None
|
|
347
|
+
record = {"kind": kind, "task": packet.get("task"), "gateway_request_id": response.gateway_request_id,
|
|
348
|
+
"attempts": response.attempts, "repaired": kind.endswith("repair"),
|
|
349
|
+
"charged_micro": response.charged_micro, "model": response.model,
|
|
350
|
+
"usage": {name: getattr(response.usage, name) for name in response.usage.__dataclass_fields__},
|
|
351
|
+
"at": time.time()}
|
|
352
|
+
problem = None
|
|
353
|
+
answer = None
|
|
354
|
+
try:
|
|
355
|
+
value = response.json()
|
|
356
|
+
if not isinstance(value, dict):
|
|
357
|
+
raise ValueError("not an object")
|
|
358
|
+
answer = _schema.decode(value, source) if source else value
|
|
359
|
+
except ValueError:
|
|
360
|
+
problem = "the reply was not one JSON object"
|
|
361
|
+
record["ok"] = problem is None
|
|
362
|
+
with self._lock:
|
|
363
|
+
self.state.setdefault("packets", []).append(record)
|
|
364
|
+
self.state["charged_micro"] = int(self.state.get("charged_micro") or 0) + response.charged_micro
|
|
365
|
+
usage = self.state.setdefault("usage", {})
|
|
366
|
+
for name, value in record["usage"].items():
|
|
367
|
+
usage[name] = int(usage.get(name) or 0) + int(value or 0)
|
|
368
|
+
self._save()
|
|
369
|
+
self.trace.call(self.run_id, worker=0, seq=number, kind=kind, packet_id=kind, capability=self.capability,
|
|
370
|
+
task=packet.get("task"), model=response.model, provider=response.provider,
|
|
371
|
+
input_uncached=response.usage.input_uncached, cache_read=response.usage.cache_read,
|
|
372
|
+
cache_write=response.usage.cache_write, output_tokens=response.usage.output_tokens,
|
|
373
|
+
price_micro=response.price_micro, charged_micro=response.charged_micro,
|
|
374
|
+
cost_micro=response.cost_micro, gateway_request_id=response.gateway_request_id,
|
|
375
|
+
latency_ms=response.latency_ms, valid=problem is None, attempts=response.attempts,
|
|
376
|
+
problem=problem)
|
|
377
|
+
self._emit("usage", packet=kind, charged_micro=response.charged_micro,
|
|
378
|
+
total_charged_micro=self.state["charged_micro"], input_tokens=response.usage.input_total,
|
|
379
|
+
cache_read=response.usage.cache_read, output_tokens=response.usage.output_tokens)
|
|
380
|
+
return answer, response.text, problem
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _turn(packet: dict) -> dict:
|
|
384
|
+
return {"role": "user", "content": [text_block(canonical(packet.get("input") or {}) + "\n\n"
|
|
385
|
+
+ ANSWER_RULE)]}
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _repair_turns(messages: list, text: str, problems: list) -> list:
|
|
389
|
+
said = (text or "").strip() or "(no answer)"
|
|
390
|
+
return messages + [
|
|
391
|
+
{"role": "assistant", "content": [text_block(said[:20_000])]},
|
|
392
|
+
{"role": "user", "content": [text_block(
|
|
393
|
+
"That answer has problems:\n" + _problems_text(problems)
|
|
394
|
+
+ "\nAnswer again with ONE corrected JSON object for the same packet, and nothing else.")]}]
|
|
395
|
+
|
|
396
|
+
def _paused(self, exc: GatewayError) -> dict:
|
|
397
|
+
self._update(paused={"code": exc.code, "message": str(exc)})
|
|
398
|
+
self._emit("paused", code=exc.code, message=str(exc),
|
|
399
|
+
resume="retry once the account can pay for it (or the provider is back)")
|
|
400
|
+
return self.status()
|
|
401
|
+
|
|
402
|
+
def _fail(self, reason: str) -> dict:
|
|
403
|
+
self._update(phase="failed", reason=reason, paused=None)
|
|
404
|
+
self._emit("failed", reason=reason)
|
|
405
|
+
self.trace.finish_run(self.run_id, "failed", {"reason": reason})
|
|
406
|
+
return self.status()
|
|
407
|
+
|
|
408
|
+
# -- compose ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def compose(self, goal: str, dataset: str) -> dict:
|
|
411
|
+
"""Compose a plan for `goal` on `dataset`: one packet, one repair at most."""
|
|
412
|
+
self._update(phase="composing", goal=goal, dataset=dataset, reason=None, paused=None, plan_id=None,
|
|
413
|
+
confirmed=False, summary=None, report=None)
|
|
414
|
+
try:
|
|
415
|
+
packet = self.host.compose_packet(goal, dataset)
|
|
416
|
+
except Exception as exc: # noqa: BLE001 -- the run says why
|
|
417
|
+
return self._fail(f"the compose packet could not be built: {type(exc).__name__}: {exc}")
|
|
418
|
+
messages = [self._turn(packet)]
|
|
419
|
+
plan_id = None
|
|
420
|
+
view = None
|
|
421
|
+
answer = None
|
|
422
|
+
problems: list = []
|
|
423
|
+
for attempt in range(self.max_repairs + 1):
|
|
424
|
+
kind = "compose" if attempt == 0 else "compose_repair"
|
|
425
|
+
try:
|
|
426
|
+
answer, text, problem = self._call(kind, packet, messages)
|
|
427
|
+
except _Paused as paused:
|
|
428
|
+
return self._paused(paused.exc)
|
|
429
|
+
except _Failed as failed:
|
|
430
|
+
return self._fail(str(failed))
|
|
431
|
+
if problem is not None:
|
|
432
|
+
problems = [{"kind": "format", "message": problem, "blocking": True}]
|
|
433
|
+
else:
|
|
434
|
+
try:
|
|
435
|
+
view, problems = self.host.accept_composed(goal, dataset, answer, plan_id=plan_id)
|
|
436
|
+
except Exception as exc: # noqa: BLE001 -- a host refusal is a reason, not a crash
|
|
437
|
+
return self._fail(f"the composed plan was refused: {type(exc).__name__}: {exc}")
|
|
438
|
+
problems = list(problems or [])
|
|
439
|
+
if view:
|
|
440
|
+
plan_id = view.get("id") or plan_id
|
|
441
|
+
if not problems:
|
|
442
|
+
break
|
|
443
|
+
self._mark_problems(problems)
|
|
444
|
+
self._emit("problems", packet="compose", attempt=attempt + 1, problems=problems)
|
|
445
|
+
if attempt < self.max_repairs:
|
|
446
|
+
messages = self._repair_turns(messages, text, problems)
|
|
447
|
+
summary = (answer or {}).get("summary") if isinstance(answer, dict) else None
|
|
448
|
+
if problems:
|
|
449
|
+
self._update(plan_id=plan_id, summary=summary)
|
|
450
|
+
return self._fail("the composed plan still has problems after a repair: "
|
|
451
|
+
+ "; ".join(str(p.get("message")) for p in problems[:5]))
|
|
452
|
+
if not view:
|
|
453
|
+
self._update(phase="nothing", plan_id=None, summary=summary, paused=None)
|
|
454
|
+
self._emit("composed", plan_id=None, summary=summary)
|
|
455
|
+
self.trace.finish_run(self.run_id, "nothing", {"summary": summary})
|
|
456
|
+
return self.status()
|
|
457
|
+
phase = phase_of(view)
|
|
458
|
+
reason = _failure_of(view) if phase == "failed" else None
|
|
459
|
+
self._update(plan_id=view.get("id"), summary=summary, paused=None, reason=reason)
|
|
460
|
+
self._emit("composed", plan_id=view.get("id"), summary=summary, status=view.get("status"),
|
|
461
|
+
steps=len(view.get("steps") or ()))
|
|
462
|
+
self._settle_phase(view, phase)
|
|
463
|
+
return self.status()
|
|
464
|
+
|
|
465
|
+
def _mark_problems(self, problems: list) -> None:
|
|
466
|
+
with self._lock:
|
|
467
|
+
if self.state.get("packets"):
|
|
468
|
+
self.state["packets"][-1]["problems"] = len(problems)
|
|
469
|
+
self._save()
|
|
470
|
+
|
|
471
|
+
def _settle_phase(self, view: dict, phase: str | None = None) -> None:
|
|
472
|
+
phase = phase or phase_of(view, confirmed=self.state.get("confirmed"))
|
|
473
|
+
reason = _failure_of(view) if phase == "failed" else None
|
|
474
|
+
self._update(phase=phase, reason=reason)
|
|
475
|
+
if phase == "awaiting_decision":
|
|
476
|
+
for decision in view.get("decisions") or ():
|
|
477
|
+
if decision.get("state") == "open":
|
|
478
|
+
self._emit("decision_needed", plan_id=view.get("id"), decision=decision)
|
|
479
|
+
elif phase == "failed":
|
|
480
|
+
self._emit("failed", reason=reason)
|
|
481
|
+
|
|
482
|
+
# -- the person -----------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def answer(self, decision_id: str, value) -> dict:
|
|
485
|
+
"""The person's answer to one decision (through the host, which
|
|
486
|
+
validates it); returns the run's status."""
|
|
487
|
+
self._require_plan()
|
|
488
|
+
view = self.host.answer(self.plan_id, decision_id, value)
|
|
489
|
+
self._emit("answered", decision_id=decision_id, answer=value)
|
|
490
|
+
if self.phase not in ("running", "summarising"):
|
|
491
|
+
self._settle_phase(view)
|
|
492
|
+
return self.status()
|
|
493
|
+
|
|
494
|
+
def resolve(self, decision_id: str, text: str) -> dict:
|
|
495
|
+
"""Which option a free-text answer means: one small packet. It only
|
|
496
|
+
suggests -- `{"option", "why"}`, option None when none fits -- and the
|
|
497
|
+
person confirms it with `answer`."""
|
|
498
|
+
self._require_plan()
|
|
499
|
+
decision = next((d for d in (self.view() or {}).get("decisions") or () if d.get("id") == decision_id),
|
|
500
|
+
None)
|
|
501
|
+
if decision is None or not decision.get("options"):
|
|
502
|
+
raise AgentError("invalid_input", f"decision {decision_id!r} has no options to match")
|
|
503
|
+
ids = [str(o.get("id")) for o in decision["options"] if isinstance(o, dict) and o.get("id") is not None]
|
|
504
|
+
packet = {"task": "plan.resolve",
|
|
505
|
+
"input": {"question": decision.get("question"), "options": decision["options"],
|
|
506
|
+
"answer": str(text)[:2000],
|
|
507
|
+
"instructions": "Which option did the person mean? null when none of them."},
|
|
508
|
+
"schema": {"type": "object", "properties": {
|
|
509
|
+
"option": {"anyOf": [{"type": "string", "enum": ids}, {"type": "null"}]},
|
|
510
|
+
"why": {"type": "string"}}, "required": ["option", "why"]}}
|
|
511
|
+
try:
|
|
512
|
+
answer, _text, problem = self._call("resolve", packet, [self._turn(packet)])
|
|
513
|
+
except _Paused as paused:
|
|
514
|
+
self._paused(paused.exc)
|
|
515
|
+
return {"option": None, "why": f"paused: {paused.exc.code}"}
|
|
516
|
+
except _Failed as failed:
|
|
517
|
+
return {"option": None, "why": str(failed)}
|
|
518
|
+
if problem or answer.get("option") not in ids:
|
|
519
|
+
return {"option": None, "why": (answer or {}).get("why") or problem or "no option matched"}
|
|
520
|
+
return {"option": answer["option"], "why": str(answer.get("why") or "")}
|
|
521
|
+
|
|
522
|
+
def stop(self, reason: str = "stopped by the person") -> dict:
|
|
523
|
+
"""Stop: nothing more runs until the person continues. A run cannot
|
|
524
|
+
be cancelled mid-step; one in progress stops after the host's current
|
|
525
|
+
call returns."""
|
|
526
|
+
if self.phase == "running":
|
|
527
|
+
self._stop_requested = True
|
|
528
|
+
self._emit("stop_requested", reason=reason)
|
|
529
|
+
return self.status()
|
|
530
|
+
self._update(phase="stopped", reason=reason)
|
|
531
|
+
self._emit("stopped", reason=reason)
|
|
532
|
+
return self.status()
|
|
533
|
+
|
|
534
|
+
def _require_plan(self) -> None:
|
|
535
|
+
if not self.plan_id:
|
|
536
|
+
raise AgentError("invalid_input", "this plan run has no plan", detail={"phase": self.phase})
|
|
537
|
+
|
|
538
|
+
# -- running ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
def confirm_and_run(self) -> dict:
|
|
541
|
+
"""The person agreed: confirm the plan and run it until it is done,
|
|
542
|
+
waits on a decision (a checkpoint returns control to the person),
|
|
543
|
+
fails or stops."""
|
|
544
|
+
return self._run(confirm=True)
|
|
545
|
+
|
|
546
|
+
def continue_run(self, *, from_step=None, until_step=None) -> dict:
|
|
547
|
+
"""Go on after a checkpoint (or a stop), or rerun from a step."""
|
|
548
|
+
if not self.state.get("confirmed"):
|
|
549
|
+
raise AgentError("invalid_input", "the plan has not been confirmed; confirm_and_run first")
|
|
550
|
+
return self._run(confirm=False, from_step=from_step, until_step=until_step)
|
|
551
|
+
|
|
552
|
+
def _run(self, *, confirm: bool, from_step=None, until_step=None) -> dict:
|
|
553
|
+
self._require_plan()
|
|
554
|
+
previous = self.phase
|
|
555
|
+
self._stop_requested = False
|
|
556
|
+
self._update(phase="running", reason=None)
|
|
557
|
+
view = None
|
|
558
|
+
try:
|
|
559
|
+
for call in range(MAX_RUN_CALLS):
|
|
560
|
+
if self._stop_requested:
|
|
561
|
+
break
|
|
562
|
+
view = self.host.run(self.plan_id, confirm=confirm and call == 0,
|
|
563
|
+
from_step=from_step if call == 0 else None,
|
|
564
|
+
until_step=until_step)
|
|
565
|
+
if call == 0 and confirm:
|
|
566
|
+
self.state["confirmed"] = True
|
|
567
|
+
self._save()
|
|
568
|
+
for ran in view.get("ran") or ():
|
|
569
|
+
self._emit("step", plan_id=self.plan_id, **{k: v for k, v in ran.items() if k != "event"})
|
|
570
|
+
if view.get("status") not in ("confirmed", "running") or not view.get("ran"):
|
|
571
|
+
break
|
|
572
|
+
except Exception:
|
|
573
|
+
self._update(phase=previous)
|
|
574
|
+
raise
|
|
575
|
+
status = view.get("status") if view else None
|
|
576
|
+
if self._stop_requested:
|
|
577
|
+
self._stop_requested = False
|
|
578
|
+
self._update(phase="stopped", reason="stopped by the person")
|
|
579
|
+
self._emit("stopped", reason="stopped by the person")
|
|
580
|
+
return self.status()
|
|
581
|
+
if status in ("confirmed", "running"):
|
|
582
|
+
self._update(phase="stopped", reason="the run made no further progress")
|
|
583
|
+
return self.status()
|
|
584
|
+
phase = phase_of(view, confirmed=True)
|
|
585
|
+
if phase == "finished":
|
|
586
|
+
self._update(phase="finished")
|
|
587
|
+
self._emit("run_finished", plan_id=self.plan_id, status=status)
|
|
588
|
+
elif phase == "stopped":
|
|
589
|
+
self._update(phase="stopped", reason=view.get("reason") or "the plan stopped")
|
|
590
|
+
self._emit("run_finished", plan_id=self.plan_id, status=status)
|
|
591
|
+
else:
|
|
592
|
+
self._settle_phase(view, phase)
|
|
593
|
+
if phase == "failed":
|
|
594
|
+
self._emit("run_finished", plan_id=self.plan_id, status=status)
|
|
595
|
+
return self.status()
|
|
596
|
+
|
|
597
|
+
# -- the summary ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
def summarise(self) -> dict:
|
|
600
|
+
"""The report: one packet, one repair at most, then settled by the host."""
|
|
601
|
+
self._require_plan()
|
|
602
|
+
self._update(phase="summarising", reason=None, paused=None)
|
|
603
|
+
try:
|
|
604
|
+
packet = self.host.summarise_packet(self.plan_id)
|
|
605
|
+
except Exception as exc: # noqa: BLE001 -- the run says why
|
|
606
|
+
return self._fail(f"the summary packet could not be built: {type(exc).__name__}: {exc}")
|
|
607
|
+
messages = [self._turn(packet)]
|
|
608
|
+
settled_on = None
|
|
609
|
+
for attempt in range(self.max_repairs + 1):
|
|
610
|
+
kind = "summarise" if attempt == 0 else "summarise_repair"
|
|
611
|
+
try:
|
|
612
|
+
answer, text, problem = self._call(kind, packet, messages)
|
|
613
|
+
except _Paused as paused:
|
|
614
|
+
return self._paused(paused.exc)
|
|
615
|
+
except _Failed as failed:
|
|
616
|
+
return self._fail(str(failed))
|
|
617
|
+
if problem is not None:
|
|
618
|
+
problems = [{"kind": "format", "message": problem}]
|
|
619
|
+
else:
|
|
620
|
+
settled_on = answer
|
|
621
|
+
try:
|
|
622
|
+
problems = list(self.host.accept_summary(self.plan_id, answer) or [])
|
|
623
|
+
except Exception as exc: # noqa: BLE001 -- the host settles what it can
|
|
624
|
+
problems = []
|
|
625
|
+
self._emit("problems", packet="summarise", attempt=attempt + 1,
|
|
626
|
+
problems=[{"message": f"{type(exc).__name__}: {exc}"}])
|
|
627
|
+
if not problems:
|
|
628
|
+
break
|
|
629
|
+
self._mark_problems(problems)
|
|
630
|
+
self._emit("problems", packet="summarise", attempt=attempt + 1, problems=problems)
|
|
631
|
+
if attempt < self.max_repairs:
|
|
632
|
+
messages = self._repair_turns(messages, text, problems)
|
|
633
|
+
if settled_on is None:
|
|
634
|
+
return self._fail("the summary was not one JSON object, twice")
|
|
635
|
+
try:
|
|
636
|
+
report = self.host.settle_summary(self.plan_id, settled_on)
|
|
637
|
+
except Exception as exc: # noqa: BLE001 -- the run says why
|
|
638
|
+
return self._fail(f"the summary could not be settled: {type(exc).__name__}: {exc}")
|
|
639
|
+
self._update(phase="reported", report={"path": report.get("path"),
|
|
640
|
+
"sentences": len(report.get("sentences") or ())})
|
|
641
|
+
self._emit("report", plan_id=self.plan_id, path=report.get("path"), markdown=report.get("markdown"),
|
|
642
|
+
sentences=report.get("sentences"))
|
|
643
|
+
self.trace.finish_run(self.run_id, "reported", {"plan_id": self.plan_id,
|
|
644
|
+
"charged_micro": self.state.get("charged_micro")})
|
|
645
|
+
return {**self.status(), "markdown": report.get("markdown"), "sentences": report.get("sentences")}
|
|
646
|
+
|
|
647
|
+
# -- after a pause or a restart --------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
def retry(self) -> dict:
|
|
650
|
+
"""Send the packet a pause (or a restart) interrupted again."""
|
|
651
|
+
if self.phase == "composing":
|
|
652
|
+
return self.compose(self.state["goal"], self.state["dataset"])
|
|
653
|
+
if self.phase == "summarising":
|
|
654
|
+
return self.summarise()
|
|
655
|
+
raise AgentError("invalid_input", f"nothing to retry in phase {self.phase!r}")
|
|
656
|
+
|
|
657
|
+
def refresh(self) -> dict:
|
|
658
|
+
"""Read the plan again (it may have been edited elsewhere) and settle the phase."""
|
|
659
|
+
view = self.view()
|
|
660
|
+
if view is not None and self.phase not in CALLING:
|
|
661
|
+
self._settle_phase(view)
|
|
662
|
+
return self.status()
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class PlanService:
|
|
666
|
+
"""Every plan run one process drives, for an HTTP host: a model call or a
|
|
667
|
+
run goes on a thread and returns at once; `events` is a held poll.
|
|
668
|
+
|
|
669
|
+
`host_factory()` gives the product's `PlanHost`, `gateway_factory()` its
|
|
670
|
+
gateway client, `system` the cached prefix (or a function returning it)."""
|
|
671
|
+
|
|
672
|
+
def __init__(self, root, *, host_factory, gateway_factory, system, trace=None, run_options: dict | None = None):
|
|
673
|
+
self.root = Path(root)
|
|
674
|
+
self.host_factory = host_factory
|
|
675
|
+
self.gateway_factory = gateway_factory
|
|
676
|
+
self._system = system
|
|
677
|
+
self.trace = trace
|
|
678
|
+
self.run_options = dict(run_options or {})
|
|
679
|
+
self._runs: dict = {}
|
|
680
|
+
self._threads: dict = {}
|
|
681
|
+
self._lock = threading.Lock()
|
|
682
|
+
self._cond = threading.Condition()
|
|
683
|
+
|
|
684
|
+
def _notify(self, event: dict) -> None:
|
|
685
|
+
with self._cond:
|
|
686
|
+
self._cond.notify_all()
|
|
687
|
+
|
|
688
|
+
def system(self) -> list:
|
|
689
|
+
return self._system() if callable(self._system) else list(self._system or [])
|
|
690
|
+
|
|
691
|
+
def _options(self) -> dict:
|
|
692
|
+
return {"system": self.system(), "on_event": self._notify, "trace": self.trace, **self.run_options}
|
|
693
|
+
|
|
694
|
+
def run(self, run_id: str) -> PlanRun:
|
|
695
|
+
with self._lock:
|
|
696
|
+
found = self._runs.get(run_id)
|
|
697
|
+
if found is None:
|
|
698
|
+
found = PlanRun.resume(self.root, run_id, self.host_factory(), self.gateway_factory(),
|
|
699
|
+
**self._options())
|
|
700
|
+
with self._lock:
|
|
701
|
+
found = self._runs.setdefault(run_id, found)
|
|
702
|
+
return found
|
|
703
|
+
|
|
704
|
+
def busy(self, run_id: str) -> bool:
|
|
705
|
+
with self._lock:
|
|
706
|
+
thread = self._threads.get(run_id)
|
|
707
|
+
return bool(thread and thread.is_alive())
|
|
708
|
+
|
|
709
|
+
def _background(self, run: PlanRun, work) -> dict:
|
|
710
|
+
with self._lock:
|
|
711
|
+
thread = self._threads.get(run.run_id)
|
|
712
|
+
if thread and thread.is_alive():
|
|
713
|
+
raise AgentError("conflict", "this plan is still working on the last request",
|
|
714
|
+
detail={"phase": run.phase}, retryable=True)
|
|
715
|
+
|
|
716
|
+
def body():
|
|
717
|
+
try:
|
|
718
|
+
work()
|
|
719
|
+
except Exception as exc: # noqa: BLE001 -- the run's events say why
|
|
720
|
+
run._emit("error", code=getattr(exc, "code", "internal_error"),
|
|
721
|
+
message=f"{type(exc).__name__}: {exc}")
|
|
722
|
+
finally:
|
|
723
|
+
self._notify({})
|
|
724
|
+
|
|
725
|
+
thread = threading.Thread(target=body, name=f"spatialagents-plan-{run.run_id}", daemon=True)
|
|
726
|
+
self._threads[run.run_id] = thread
|
|
727
|
+
thread.start()
|
|
728
|
+
return {"run_id": run.run_id, "running": True, "phase": run.phase}
|
|
729
|
+
|
|
730
|
+
def start(self, goal: str, dataset: str) -> dict:
|
|
731
|
+
run = PlanRun(self.host_factory(), self.gateway_factory(), root=self.root, **self._options())
|
|
732
|
+
run._save()
|
|
733
|
+
with self._lock:
|
|
734
|
+
self._runs[run.run_id] = run
|
|
735
|
+
return self._background(run, lambda: run.compose(goal, dataset))
|
|
736
|
+
|
|
737
|
+
def answer(self, run_id: str, decision_id: str, value) -> dict:
|
|
738
|
+
run = self.run(run_id)
|
|
739
|
+
if self.busy(run_id):
|
|
740
|
+
raise AgentError("conflict", "this plan is still working; answer once it waits for you",
|
|
741
|
+
retryable=True)
|
|
742
|
+
return run.answer(decision_id, value)
|
|
743
|
+
|
|
744
|
+
def resolve(self, run_id: str, decision_id: str, text: str) -> dict:
|
|
745
|
+
return self.run(run_id).resolve(decision_id, text)
|
|
746
|
+
|
|
747
|
+
def confirm(self, run_id: str) -> dict:
|
|
748
|
+
run = self.run(run_id)
|
|
749
|
+
return self._background(run, run.confirm_and_run)
|
|
750
|
+
|
|
751
|
+
def proceed(self, run_id: str, *, from_step=None, until_step=None) -> dict:
|
|
752
|
+
run = self.run(run_id)
|
|
753
|
+
return self._background(run, lambda: run.continue_run(from_step=from_step, until_step=until_step))
|
|
754
|
+
|
|
755
|
+
def summarise(self, run_id: str) -> dict:
|
|
756
|
+
run = self.run(run_id)
|
|
757
|
+
return self._background(run, run.summarise)
|
|
758
|
+
|
|
759
|
+
def retry(self, run_id: str) -> dict:
|
|
760
|
+
run = self.run(run_id)
|
|
761
|
+
return self._background(run, run.retry)
|
|
762
|
+
|
|
763
|
+
def stop(self, run_id: str) -> dict:
|
|
764
|
+
return self.run(run_id).stop()
|
|
765
|
+
|
|
766
|
+
def status(self, run_id: str) -> dict:
|
|
767
|
+
return {**self.run(run_id).status(), "running": self.busy(run_id)}
|
|
768
|
+
|
|
769
|
+
def wait(self, run_id: str, timeout: float | None = None) -> bool:
|
|
770
|
+
with self._lock:
|
|
771
|
+
thread = self._threads.get(run_id)
|
|
772
|
+
if thread is None:
|
|
773
|
+
return True
|
|
774
|
+
thread.join(timeout)
|
|
775
|
+
return not thread.is_alive()
|
|
776
|
+
|
|
777
|
+
def events(self, run_id: str, after: int = 0, wait_s: float = 0.0) -> dict:
|
|
778
|
+
run = self.run(run_id)
|
|
779
|
+
deadline = time.monotonic() + max(0.0, min(float(wait_s or 0), 25.0))
|
|
780
|
+
with self._cond:
|
|
781
|
+
while True:
|
|
782
|
+
found = run.events(after)
|
|
783
|
+
remaining = deadline - time.monotonic()
|
|
784
|
+
if found or remaining <= 0 or not (self.busy(run_id) or wait_s):
|
|
785
|
+
break
|
|
786
|
+
self._cond.wait(min(remaining, 1.0))
|
|
787
|
+
return {"run_id": run_id, "events": found, "after": found[-1]["seq"] if found else after,
|
|
788
|
+
"running": self.busy(run_id), "phase": run.phase}
|
|
789
|
+
|
|
790
|
+
def list(self, limit: int = 50) -> list:
|
|
791
|
+
out = []
|
|
792
|
+
for path in sorted(self.root.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:limit]:
|
|
793
|
+
try:
|
|
794
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
795
|
+
except (OSError, ValueError):
|
|
796
|
+
continue
|
|
797
|
+
out.append({k: state.get(k) for k in ("run_id", "phase", "goal", "dataset", "plan_id",
|
|
798
|
+
"charged_micro", "created_at")})
|
|
799
|
+
return out
|
|
800
|
+
|