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/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
+