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.
@@ -0,0 +1,813 @@
1
+ """The conversational agent: a tool loop through the gateway.
2
+
3
+ runner = AgentRunner.create(store, gateway=GatewayClient(tokens), policy=Policy(),
4
+ backend=MyBackend(), profile=MY_PROFILE)
5
+ for event in runner.turn("Which markers in melanoma_01 look unreliable?"):
6
+ ... # text_delta, tool_call, tool_result, approval_requested, usage, done, ...
7
+
8
+ One turn is: the user's message, then model call -> tool calls -> model call
9
+ ... until the model answers without calling a tool. Every model call goes to
10
+ the gateway with capability `text_reasoning` (`vision_routine` once the
11
+ conversation holds an image), the profile's feature (`chat`) and its task
12
+ (`chat.turn`, `chat.subagent` for a sub-agent: on the wire
13
+ `<product>.chat.turn`).
14
+
15
+ **What a product binds.** The tools are a `backend.ToolBackend`; everything
16
+ the conversation says in the product's name -- the system prompt, the
17
+ sub-agent brief, the disclosure, the compaction note, the local tools'
18
+ descriptions -- and the task names are a `ChatProfile`; the trace, the cache
19
+ and the offload root are the product's places on disk. Nothing here names a
20
+ product.
21
+
22
+ **The prefix** is byte-stable and frozen at conversation start: tools (the
23
+ local tools only; catalog tools are loaded into the history by `load_tool`
24
+ and run through `call_tool`, so the array never changes) -> system (the
25
+ profile's `prefix(catalog_text)`: identity and house rules, a skill, the tool
26
+ catalog, `prefix.build_prefix`) with ONE cache breakpoint on the catalog. Each
27
+ call also marks the newest message and the user turn before it
28
+ (`wire.with_breakpoints`, on a copy), so it reads the conversation so far from
29
+ cache. The conversation record keeps it, so a resumed conversation sends the
30
+ very same bytes. Nothing per-conversation is in it.
31
+
32
+ **Compaction.** Past `compact_at_tokens` (50k) of history the older turns are
33
+ replaced, locally and deterministically, by a summary placed at the start of
34
+ the kept window: the prefix is never edited, so it still reads from cache.
35
+
36
+ **Control.** Before every model call and every tool call the runner reads the
37
+ conversation's `control.json`: `stopped` ends the turn; `paused` waits (for the
38
+ user's Resume, or for an approval). A credit refusal from the gateway PAUSES
39
+ the conversation (`paused_by: "credits"`) and ends the turn with a `paused`
40
+ event; the next message continues it.
41
+
42
+ **Sub-agents** (`spawn_agents`): each forks the parent's exact prefix (same
43
+ tools array, same system: the fork rule, so it reads the parent's cache) and
44
+ starts with its brief after the breakpoint. It may call only its tool subset,
45
+ shares the parent's approvals, and returns a typed summary. They run on the
46
+ orchestrator's `Scheduler` (`orchestrator.py`), staggered so the first warms
47
+ the cache, to `max_depth` levels; `read_board`/`post_board` share facts.
48
+
49
+ Events are plain dicts with an `event` key; a sub-agent's carry `agent`.
50
+ `turn()` runs the work on a thread and yields events as they happen, so a
51
+ consumer sees `approval_requested` while the call waits for the answer.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import json
57
+ import logging
58
+ import queue
59
+ import threading
60
+ import time
61
+ import uuid
62
+ from dataclasses import dataclass, field
63
+ from datetime import datetime, timezone
64
+ from typing import Callable
65
+
66
+ from spatialagents import cache_plan
67
+ from spatialagents import prefix as _prefix
68
+ from spatialagents.approvals import DECLINED, ApprovalGate
69
+ from spatialagents.gateway import PAUSE_CODES, GatewayError
70
+ from spatialagents.orchestrator import Blackboard, Scheduler, TaskGraph
71
+ from spatialagents.policy import elevated, policy_from
72
+ from spatialagents.sessions import new_session_id
73
+ from spatialagents.tools import (AWAIT_AGENTS, CALL_TOOL, POST_BOARD, READ_BOARD, SPAWN_AGENTS, SUBAGENT_LOCAL,
74
+ ToolAdapter, error_outcome)
75
+ from spatialagents.trace import NullTrace
76
+ from spatialagents.wire import ModelRequest, canonical, image_block, text_block, with_breakpoints
77
+
78
+ log = logging.getLogger("spatialagents")
79
+
80
+ __all__ = ["AgentRunner", "ChatProfile", "DISCLOSURE", "Halted", "TurnFailed", "policy_from", "summarize_turns"]
81
+
82
+ TEXT_CAPABILITY = "text_reasoning"
83
+ VISION_CAPABILITY = "vision_routine"
84
+ DISCLOSURE = "AI-generated; verify before relying on it."
85
+ COMPACT_AT_TOKENS = 50_000
86
+ #: The gateway's per-request image limit is 24; older images are dropped past this.
87
+ KEEP_IMAGES = 20
88
+ TOKENS_PER_IMAGE = 1600
89
+ MAX_SUBAGENTS = 8
90
+ SUMMARY_CHARS = 2000
91
+
92
+
93
+ def _tokens(messages: list) -> int:
94
+ chars, images = 0, 0
95
+
96
+ def walk(content):
97
+ nonlocal chars, images
98
+ if isinstance(content, str):
99
+ chars += len(content)
100
+ return
101
+ for block in content or []:
102
+ kind = block.get("type")
103
+ if kind == "text":
104
+ chars += len(block.get("text", ""))
105
+ elif kind == "image":
106
+ images += 1
107
+ elif kind == "tool_use":
108
+ chars += len(canonical(block.get("input") or {})) + 40
109
+ elif kind == "tool_result":
110
+ walk(block.get("content"))
111
+ for message in messages:
112
+ walk(message.get("content"))
113
+ return int(chars / cache_plan.CHARS_PER_TOKEN) + images * TOKENS_PER_IMAGE
114
+
115
+
116
+ def _has_images(messages: list) -> bool:
117
+ def walk(content):
118
+ for block in content if isinstance(content, list) else []:
119
+ if block.get("type") == "image":
120
+ return True
121
+ if block.get("type") == "tool_result" and walk(block.get("content")):
122
+ return True
123
+ return False
124
+ return any(walk(m.get("content")) for m in messages)
125
+
126
+
127
+ def _is_turn_start(message: dict) -> bool:
128
+ content = message.get("content")
129
+ return message.get("role") == "user" and not any(
130
+ b.get("type") == "tool_result" for b in (content if isinstance(content, list) else []))
131
+
132
+
133
+ def summarize_turns(messages: list) -> str:
134
+ """A deterministic summary of `messages` for compaction: what the user
135
+ asked, what was called and what came back, what was answered."""
136
+ lines = []
137
+ for message in messages:
138
+ content = message.get("content")
139
+ blocks = content if isinstance(content, list) else [{"type": "text", "text": str(content)}]
140
+ for block in blocks:
141
+ kind = block.get("type")
142
+ if kind == "text" and block.get("text", "").strip():
143
+ who = "User" if message.get("role") == "user" else "Assistant"
144
+ text = " ".join(block["text"].split())
145
+ lines.append(f"{who}: {text[:300]}{'...' if len(text) > 300 else ''}")
146
+ elif kind == "tool_use":
147
+ args = canonical(block.get("input") or {})
148
+ lines.append(f"Called {block.get('name')}({args[:160]}{'...' if len(args) > 160 else ''})")
149
+ elif kind == "tool_result":
150
+ inner = block.get("content")
151
+ text = inner if isinstance(inner, str) else " ".join(
152
+ b.get("text", "") for b in inner or [] if b.get("type") == "text")
153
+ mark = "error" if block.get("is_error") else "result"
154
+ lines.append(f" -> {mark}: {' '.join(text.split())[:200]}")
155
+ return "\n".join(lines)
156
+
157
+
158
+ def _parse_summary(text: str) -> dict:
159
+ text = (text or "").strip()
160
+ try:
161
+ start, end = text.find("{"), text.rfind("}")
162
+ value = json.loads(text[start:end + 1]) if start >= 0 and end > start else None
163
+ except ValueError:
164
+ value = None
165
+ if isinstance(value, dict) and "summary" in value:
166
+ return {"summary": str(value.get("summary"))[:SUMMARY_CHARS],
167
+ "findings": [str(f)[:400] for f in (value.get("findings") or [])][:20],
168
+ "operation_ids": [str(o) for o in (value.get("operation_ids") or [])][:50],
169
+ "artifact_ids": [str(a) for a in (value.get("artifact_ids") or [])][:50]}
170
+ return {"summary": text[:SUMMARY_CHARS], "findings": [], "operation_ids": [], "artifact_ids": []}
171
+
172
+
173
+ def now_iso() -> str:
174
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class ChatProfile:
179
+ """What a product's conversation says and is filed as, beside its tools.
180
+
181
+ `prefix(catalog_text)` is the system prompt (`prefix.build_prefix` over the
182
+ product's identity, house rules and skill); `subagent_brief(role, brief,
183
+ tools, inputs)` a sub-agent's first turn. The tasks are the product's own
184
+ names for a turn and a sub-agent's turn (`module.task`; the gateway client
185
+ puts the product in front), `feature` the gateway feature and fallback
186
+ module. `tool_descriptions` replace the neutral descriptions of the local
187
+ tools that describe the product (`list_skills`, `server_info`)."""
188
+
189
+ prefix: Callable[[str], list] = _prefix.default_prefix
190
+ subagent_brief: Callable[..., str] = _prefix.subagent_brief
191
+ disclosure: str = DISCLOSURE
192
+ turn_task: str = "chat.turn"
193
+ subagent_task: str = "chat.subagent"
194
+ feature: str = "chat"
195
+ summary_heading: str = "[Summary of the earlier conversation, made when it grew long]"
196
+ tool_descriptions: dict = field(default_factory=dict)
197
+
198
+
199
+ DEFAULT_PROFILE = ChatProfile()
200
+
201
+
202
+ class Halted(Exception):
203
+ """The user stopped the conversation."""
204
+
205
+
206
+ class TurnFailed(Exception):
207
+ """The gateway refused or failed for a reason a pause does not fix; the
208
+ turn ends with its `error` event and the conversation stays usable."""
209
+
210
+
211
+ class AgentRunner:
212
+ """One agent of a conversation: the main one, or a sub-agent (`parent`)."""
213
+
214
+ def __init__(self, conversation_id: str, *, store, gateway, adapter: ToolAdapter, system: list,
215
+ messages: list | None = None, record: dict | None = None, policy=None, trace=None,
216
+ capability: str = TEXT_CAPABILITY, vision_capability: str = VISION_CAPABILITY,
217
+ model: str | None = None, max_tokens: int = 8192, compact_at_tokens: int = COMPACT_AT_TOKENS,
218
+ max_steps: int = 40, depth: int = 0, max_depth: int = 1, max_parallel: int = 4,
219
+ agent_id: str = "main", role: str = "main", allowed: set | None = None,
220
+ parent: "AgentRunner | None" = None, board: Blackboard | None = None,
221
+ approvals: ApprovalGate | None = None, poll_s: float = 0.1,
222
+ profile: ChatProfile | None = None, logger: logging.Logger | None = None):
223
+ self.conversation_id = conversation_id
224
+ self.store = store
225
+ self.gateway = gateway
226
+ self.adapter = adapter
227
+ self.system = system
228
+ self.messages = list(messages or [])
229
+ self.record = record
230
+ self.policy = policy or adapter.policy
231
+ self.trace = trace or (parent.trace if parent else NullTrace())
232
+ self.profile = profile or (parent.profile if parent else DEFAULT_PROFILE)
233
+ self.log = logger or (parent.log if parent else log)
234
+ self.capability = capability
235
+ self.vision_capability = vision_capability
236
+ self.model = model
237
+ self.max_tokens = max_tokens
238
+ self.compact_at_tokens = compact_at_tokens
239
+ self.max_steps = max_steps
240
+ self.depth = depth
241
+ self.max_depth = max_depth
242
+ self.max_parallel = max_parallel
243
+ self.agent_id = agent_id
244
+ self.role = role
245
+ self.allowed = allowed
246
+ self.parent = parent
247
+ self.root = parent.root if parent else self
248
+ self.board = board or (parent.board if parent else Blackboard())
249
+ self.approvals = approvals or (parent.approvals if parent else ApprovalGate(store, conversation_id,
250
+ poll_s=poll_s))
251
+ self.poll_s = poll_s
252
+ self.monitor = parent.monitor if parent else cache_plan.CacheMonitor(log=self.log)
253
+ self._calls = 0
254
+ self._made = 0
255
+ self._ctx = None
256
+ self._tools_seen = None
257
+ self._pending: dict[str, dict] = {} # wait=false sub-agents: id -> {graph, thread}
258
+ # This turn's own writes, per guarded scope: revision before -> revision after.
259
+ self._advanced: dict[tuple, str] = {}
260
+ self._lock = threading.Lock()
261
+ self.tool_calls = 0
262
+ self.charged = 0
263
+ if self.record is not None and self.record.get("board"):
264
+ for key, value in self.record["board"].items():
265
+ self.board.post(key, value, by="restored")
266
+
267
+ # -- construction ------------------------------------------------------------------
268
+
269
+ @classmethod
270
+ def create(cls, store, *, gateway, policy, backend, title: str | None = None, viewer: bool = False,
271
+ cache=None, trace=None, profile: ChatProfile | None = None, offload_root=None,
272
+ **options) -> "AgentRunner":
273
+ """Start a conversation: freeze the catalog and the prefix, write the record.
274
+
275
+ `backend` is the product's `ToolBackend`; `offload_root` where large
276
+ results go (default: the conversation's own `offload/` folder)."""
277
+ profile = profile or DEFAULT_PROFILE
278
+ cid = new_session_id("cv")
279
+ adapter = ToolAdapter(backend, policy=policy, cache=cache, viewer=viewer,
280
+ offload_root=offload_root or store.folder(cid) / "offload",
281
+ descriptions=profile.tool_descriptions)
282
+ system = profile.prefix(adapter.catalog_text())
283
+ tools = adapter.definitions()
284
+ record = {"session_id": cid, "kind": "chat", "created_at": now_iso(), "title": title or "",
285
+ "state": "idle", "policy": policy.describe(), "viewer": viewer,
286
+ "catalog": adapter.catalog, "system": system, "tools_initial": len(tools), "loaded": [],
287
+ "prefix_fp": cache_plan.fingerprint([tools, system]), "calls": 0, "turns": 0,
288
+ "charged_micro": 0, "usage": {}, "board": {}, "disclosure": profile.disclosure}
289
+ store.create(record)
290
+ store.save_messages(cid, [])
291
+ runner = cls(cid, store=store, gateway=gateway, adapter=adapter, system=system, record=record,
292
+ policy=policy, trace=trace, profile=profile, **options)
293
+ runner.trace.start_run(cid, "chat", session_id=cid, capability=runner.capability,
294
+ billing="dev" if getattr(gateway, "dev", False) else "credits")
295
+ return runner
296
+
297
+ @classmethod
298
+ def open(cls, store, conversation_id: str, *, gateway, backend, policy=None, cache=None, trace=None,
299
+ profile: ChatProfile | None = None, offload_root=None, **options) -> "AgentRunner":
300
+ """Resume a conversation from its record: the same frozen prefix bytes."""
301
+ profile = profile or DEFAULT_PROFILE
302
+ record = store.load(conversation_id)
303
+ frozen = policy_from(record.get("policy"))
304
+ adapter = ToolAdapter(backend, policy=frozen, catalog=record.get("catalog") or [],
305
+ loaded=record.get("loaded") or [], cache=cache,
306
+ offload_root=offload_root or store.folder(conversation_id) / "offload",
307
+ descriptions=profile.tool_descriptions)
308
+ return cls(conversation_id, store=store, gateway=gateway, adapter=adapter, system=record["system"],
309
+ messages=store.load_messages(conversation_id), record=record, policy=policy or frozen,
310
+ trace=trace, profile=profile, **options)
311
+
312
+ # -- the turn ------------------------------------------------------------------------
313
+
314
+ def turn(self, user_text: str, images=()):
315
+ """Yield the events of one turn as they happen."""
316
+ events: queue.Queue = queue.Queue()
317
+ done = object()
318
+
319
+ def work():
320
+ try:
321
+ self.run_turn(user_text, images, events.put)
322
+ except Exception as exc: # noqa: BLE001 -- surfaced as an event
323
+ self.log.exception("chat turn failed in %s", self.conversation_id)
324
+ events.put({"event": "error", "code": "internal_error", "message": f"{type(exc).__name__}: {exc}"})
325
+ finally:
326
+ events.put(done)
327
+
328
+ thread = threading.Thread(target=work, name=f"spatialagents-chat-{self.conversation_id}", daemon=True)
329
+ thread.start()
330
+ while True:
331
+ event = events.get()
332
+ if event is done:
333
+ break
334
+ yield event
335
+ thread.join()
336
+
337
+ def run_turn(self, user_text: str, images, emit) -> dict:
338
+ """One turn, synchronously, announcing through `emit(event)`."""
339
+ cid = self.conversation_id
340
+ self.store.set_control(cid, stopped=False, paused=False, paused_by=None, reason=None)
341
+ content = [image_block(data, fmt) for data, fmt in _images(images)]
342
+ notes = (self.record or {}).pop("notes", None) or []
343
+ if user_text:
344
+ content.append(text_block("\n".join(notes + [user_text]) if notes else user_text))
345
+ if not content:
346
+ emit({"event": "error", "code": "invalid_input", "message": "say something"})
347
+ return {"state": "idle"}
348
+ self._append_user(content)
349
+ self._advanced = {}
350
+ self._set_state("running", turns=int((self.record or {}).get("turns") or 0) + 1)
351
+ emit({"event": "turn_started", "turn": (self.record or {}).get("turns"), "images": len(content) - bool(user_text)})
352
+ try:
353
+ outcome = self._loop(emit)
354
+ except Halted:
355
+ outcome = {"state": "stopped"}
356
+ emit({"event": "stopped"})
357
+ except TurnFailed:
358
+ outcome = {"state": "idle"}
359
+ self._save()
360
+ self._set_state(outcome["state"] if outcome["state"] != "done" else "idle")
361
+ return outcome
362
+
363
+ def _loop(self, emit) -> dict:
364
+ text = ""
365
+ for _ in range(self.max_steps):
366
+ self._halt_check()
367
+ response = self._model_call(emit)
368
+ if response is None:
369
+ return {"state": "paused"}
370
+ text = "".join(b.get("text", "") for b in response.blocks if b["type"] == "text")
371
+ blocks = [b for b in response.blocks if b["type"] in ("text", "tool_use")] or [text_block("(no answer)")]
372
+ self.messages.append({"role": "assistant", "content": blocks})
373
+ if text:
374
+ emit({"event": "text", "text": text, "agent": self._tag()})
375
+ uses = response.tool_uses
376
+ if not uses:
377
+ self._save()
378
+ emit({"event": "done", "text": text, "agent": self._tag(), "stop_reason": response.stop_reason})
379
+ return {"state": "done", "text": text}
380
+ results = []
381
+ halted = False
382
+ for use in uses:
383
+ if halted:
384
+ results.append(error_outcome(use["id"], use["name"], "stopped by the user").block())
385
+ continue
386
+ try:
387
+ self._halt_check()
388
+ outcome = self._tool(use, emit)
389
+ except Halted:
390
+ halted = True
391
+ results.append(error_outcome(use["id"], use["name"], "stopped by the user").block())
392
+ continue
393
+ results.append(outcome.block())
394
+ self.messages.append({"role": "user", "content": results})
395
+ self._save()
396
+ if halted:
397
+ raise Halted()
398
+ emit({"event": "error", "code": "max_steps", "message": f"stopped after {self.max_steps} model calls "
399
+ "in one turn", "agent": self._tag()})
400
+ return {"state": "done", "text": text}
401
+
402
+ # -- control -------------------------------------------------------------------------------
403
+
404
+ def _halt_check(self) -> None:
405
+ """Raise `Halted` on stop; wait while paused (by the user or an approval)."""
406
+ while True:
407
+ control = self.store.control(self.conversation_id)
408
+ if control.get("stopped"):
409
+ raise Halted()
410
+ if not control.get("paused") or control.get("paused_by") == "credits":
411
+ return
412
+ time.sleep(self.poll_s)
413
+
414
+ def _set_state(self, state: str, **fields) -> None:
415
+ if self.record is None:
416
+ return
417
+ self.record.update(state=state, **fields)
418
+ self._save_record()
419
+
420
+ def _save_record(self) -> None:
421
+ if self.record is None:
422
+ return
423
+ with self.store.lock(self.conversation_id):
424
+ self.record["loaded"] = self.adapter.loaded
425
+ self.record["board"] = self.board.facts()
426
+ self.store.save(self.record)
427
+
428
+ def _save(self) -> None:
429
+ if self.parent is None and self.record is not None:
430
+ self.store.save_messages(self.conversation_id, self.messages)
431
+ self._save_record()
432
+
433
+ def _tag(self):
434
+ return None if self.parent is None else self.agent_id
435
+
436
+ def _append_user(self, content: list) -> None:
437
+ # After a pause the history may end on a user turn (tool results the
438
+ # model never saw): the new words join it, so roles still alternate.
439
+ if self.messages and self.messages[-1]["role"] == "user":
440
+ previous = self.messages[-1]["content"]
441
+ previous = previous if isinstance(previous, list) else [text_block(str(previous))]
442
+ self.messages[-1] = {"role": "user", "content": previous + content}
443
+ else:
444
+ self.messages.append({"role": "user", "content": content})
445
+
446
+ # -- the model --------------------------------------------------------------------------------
447
+
448
+ def _compact(self, emit) -> None:
449
+ if _tokens(self.messages) <= self.compact_at_tokens:
450
+ return
451
+ budget = self.compact_at_tokens // 2
452
+ cut = None
453
+ for index in range(1, len(self.messages)):
454
+ if _is_turn_start(self.messages[index]) and _tokens(self.messages[index:]) <= budget:
455
+ cut = index
456
+ break
457
+ if cut is None:
458
+ return
459
+ dropped, kept = self.messages[:cut], self.messages[cut:]
460
+ summary = text_block(self.profile.summary_heading + "\n" + summarize_turns(dropped))
461
+ first = kept[0]
462
+ kept[0] = {"role": "user", "content": [summary] + list(first["content"])}
463
+ before = _tokens(self.messages)
464
+ self.messages = kept
465
+ emit({"event": "compacted", "dropped_messages": len(dropped), "tokens_before": before,
466
+ "tokens_after": _tokens(self.messages), "agent": self._tag()})
467
+
468
+ def _limit_images(self) -> None:
469
+ seen = 0
470
+ for message in reversed(self.messages):
471
+ content = message.get("content")
472
+ if not isinstance(content, list):
473
+ continue
474
+ for i in range(len(content) - 1, -1, -1):
475
+ block = content[i]
476
+ if block.get("type") == "image":
477
+ seen += 1
478
+ if seen > KEEP_IMAGES:
479
+ content[i] = text_block("[an earlier image, no longer shown]")
480
+ elif block.get("type") == "tool_result" and isinstance(block.get("content"), list):
481
+ inner = block["content"]
482
+ for j in range(len(inner) - 1, -1, -1):
483
+ if inner[j].get("type") == "image":
484
+ seen += 1
485
+ if seen > KEEP_IMAGES:
486
+ inner[j] = text_block("[an earlier image, no longer shown]")
487
+
488
+ def _model_call(self, emit):
489
+ self._compact(emit)
490
+ self._limit_images()
491
+ tools = self.adapter.definitions()
492
+ capability = self.vision_capability if _has_images(self.messages) else self.capability
493
+ root = self.root
494
+ with root._lock:
495
+ if root.record is not None:
496
+ root.record["calls"] = int(root.record.get("calls") or 0) + 1
497
+ number = root.record["calls"]
498
+ else:
499
+ root._calls += 1
500
+ number = root._calls
501
+ context = {"feature": self.profile.feature, "agent": "chat" if self.parent is None else "chat_subagent",
502
+ "workflow": "conversation", "session_id": self.conversation_id, "attempt": 1}
503
+ # A marked copy: the saved conversation stays unmarked (wire.with_breakpoints).
504
+ # One task per kind of conversation: the gateway picks its model; images keep their own class for
505
+ # gateways that route by capability.
506
+ task = self.profile.turn_task if self.parent is None else self.profile.subagent_task
507
+ request = ModelRequest(capability=capability, system=self.system, messages=with_breakpoints(self.messages),
508
+ tools=tools, max_tokens=self.max_tokens, context=context, model=self.model, task=task)
509
+ key = f"{self.conversation_id}.{self.agent_id}.{number}"
510
+
511
+ def delta(piece):
512
+ emit({"event": "text_delta", "text": piece, "agent": self._tag()})
513
+
514
+ try:
515
+ response = self.gateway.messages(request, idempotency_key=key, on_delta=delta)
516
+ except GatewayError as exc:
517
+ if exc.code in PAUSE_CODES:
518
+ self.store.set_control(self.conversation_id, paused=True, paused_by="credits", reason=exc.code)
519
+ emit({"event": "paused", "reason": exc.code, "message": str(exc), "agent": self._tag(),
520
+ "resume": "send another message once credit is available"})
521
+ return None
522
+ emit({"event": "error", "code": exc.code, "message": str(exc), "agent": self._tag()})
523
+ raise TurnFailed(exc.code) from None
524
+ prefix_now = cache_plan.fingerprint([tools, self.system])
525
+ changed = self._tools_seen is not None and self._tools_seen != len(tools)
526
+ history = self.parent is None and any(m["role"] == "assistant" for m in self.messages[:-1])
527
+ warm = (self._made > 0 or history) and not changed
528
+ self._tools_seen = len(tools)
529
+ self._made += 1
530
+ verdict = self.monitor.observe(prefix_now, cache_plan.expected_tokens([tools, self.system]),
531
+ response.usage, warm=warm)
532
+ self.charged += response.charged_micro
533
+ if self.parent is not None:
534
+ with root._lock:
535
+ root.charged += response.charged_micro
536
+ if root.record is not None:
537
+ with root._lock:
538
+ root.record["charged_micro"] = int(root.record.get("charged_micro") or 0) + response.charged_micro
539
+ usage = root.record.setdefault("usage", {})
540
+ for name in ("input_uncached", "cache_read", "cache_write_5m", "cache_write_1h", "output_tokens"):
541
+ usage[name] = int(usage.get(name) or 0) + getattr(response.usage, name)
542
+ self.trace.call(self.conversation_id, worker=self.depth, seq=number, kind="chat",
543
+ packet_id=self.agent_id, capability=capability, task=task, model=response.model,
544
+ provider=response.provider,
545
+ prefix_fp=(root.record or {}).get("prefix_fp"), verdict=verdict,
546
+ input_uncached=response.usage.input_uncached, cache_read=response.usage.cache_read,
547
+ cache_write=response.usage.cache_write, output_tokens=response.usage.output_tokens,
548
+ price_micro=response.price_micro, charged_micro=response.charged_micro,
549
+ cost_micro=response.cost_micro, gateway_request_id=response.gateway_request_id,
550
+ latency_ms=response.latency_ms)
551
+ emit({"event": "usage", "charged_micro": response.charged_micro,
552
+ "total_charged_micro": int((root.record or {}).get("charged_micro") or root.charged),
553
+ "balance_micro": (response.balance or {}).get("available_micro"), "verdict": verdict,
554
+ "capability": capability, "input_tokens": response.usage.input_total,
555
+ "cache_read": response.usage.cache_read, "output_tokens": response.usage.output_tokens,
556
+ "agent": self._tag()})
557
+ if self.parent is not None and self._made == 1 and self._ctx is not None:
558
+ self._ctx.warm() # the first sub-agent's first call wrote the cache; release the rest
559
+ return response
560
+
561
+ # -- tools ------------------------------------------------------------------------------------
562
+
563
+ def _tool(self, use: dict, emit):
564
+ tid = use.get("id") or ""
565
+ name, args = self.adapter.resolve(use.get("name") or "", use.get("input") or {})
566
+ if not name:
567
+ return error_outcome(tid, CALL_TOOL, "call_tool needs the name of a catalog tool.", source="local")
568
+ self.tool_calls += 1
569
+ args = self._carry_revision(args)
570
+ emit({"event": "tool_call", "tool_use_id": tid, "tool": name, "arguments": args, "agent": self._tag()})
571
+ if name == SPAWN_AGENTS:
572
+ outcome = self._spawn(tid, args, emit)
573
+ elif name == AWAIT_AGENTS:
574
+ outcome = self._await(tid, args)
575
+ elif name == READ_BOARD:
576
+ outcome = self.adapter.wrap(tid, name, self._read_board(args.get("keys") or []))
577
+ elif name == POST_BOARD:
578
+ key = str(args.get("key") or "")
579
+ if not key:
580
+ outcome = error_outcome(tid, name, "post_board needs a key", source="local")
581
+ else:
582
+ version = self.board.post(key, args.get("value"), by=self.agent_id)
583
+ outcome = self.adapter.wrap(tid, name, {"key": key, "version": version})
584
+ elif self.adapter.needs_approval(name, args) and (self.allowed is None or name in self.allowed):
585
+ outcome = self._approved_call(tid, name, args, emit)
586
+ else:
587
+ outcome = self.adapter.execute(tid, name, args, policy=self.policy, allowed=self.allowed)
588
+ self._note_revision(outcome)
589
+ self.trace.tool_call(self.conversation_id, agent=self.agent_id, tool=name, capability=outcome.capability,
590
+ permission=outcome.permission, source=outcome.source, ok=not outcome.is_error,
591
+ offloaded=bool(outcome.offloaded), operation_id=outcome.operation_id,
592
+ latency_ms=outcome.latency_ms)
593
+ emit({"event": "tool_result", "tool_use_id": tid, "tool": name, "ok": not outcome.is_error,
594
+ "source": outcome.source, "permission": outcome.permission, "operation_id": outcome.operation_id,
595
+ "undo": outcome.undo, "offloaded": outcome.offloaded, "summary": outcome.summary,
596
+ "images": [{"format": fmt, "data": _b64(data)} for data, fmt in outcome.images[:2]],
597
+ "agent": self._tag()})
598
+ return outcome
599
+
600
+ def _carry_revision(self, args: dict) -> dict:
601
+ """A write that names a revision this turn's own earlier write replaced
602
+ goes out with the newer one.
603
+
604
+ Models send several writes in one response, each carrying the revision
605
+ they read before the first ran (gate CD3e and Ecad together, both at
606
+ X). They run in order, so the first moves the project to Y and the
607
+ second was refused as a conflict with nothing else having happened --
608
+ a retry costing a read and a model call. Only revisions this runner's
609
+ own writes moved are followed (X -> Y -> Z); a change from anywhere
610
+ else -- a viewer, another session -- leaves the current revision off
611
+ that chain, and the write still conflicts as it should. The guard's
612
+ argument names are the backend's (`ToolBackend.revision_guard`)."""
613
+ guard = getattr(self.adapter.backend, "revision_guard", None)
614
+ if not guard or not isinstance(args, dict):
615
+ return args
616
+ scope_key, expected_key = guard
617
+ expected, scope = args.get(expected_key), args.get(scope_key)
618
+ if not isinstance(expected, str) or not isinstance(scope, str):
619
+ return args
620
+ current, seen = expected, {expected}
621
+ while (scope, current) in self._advanced:
622
+ current = self._advanced[(scope, current)]
623
+ if current in seen: # never loop on a revision that came back
624
+ break
625
+ seen.add(current)
626
+ return args if current == expected else {**args, expected_key: current}
627
+
628
+ def _note_revision(self, outcome) -> None:
629
+ moved = outcome.advanced if not outcome.is_error else None
630
+ if not isinstance(moved, dict):
631
+ return
632
+ scope, before, after = moved.get("scope"), moved.get("before"), moved.get("after")
633
+ if isinstance(scope, str) and isinstance(before, str) and isinstance(after, str) and before != after:
634
+ self._advanced[(scope, before)] = after
635
+
636
+ def _approved_call(self, tid: str, name: str, args: dict, emit):
637
+ tool = self.adapter.describe(name, args)
638
+ status, approval = self.approvals.ask(tool=name, capability=tool["capability"],
639
+ permission=tool["permission"], purpose=tool["purpose"],
640
+ arguments=args, emit=emit, agent=self._tag())
641
+ if status == "stopped":
642
+ raise Halted()
643
+ if status != "approved":
644
+ return error_outcome(tid, name, DECLINED, source="declined", capability=tool["capability"],
645
+ permission=tool["permission"])
646
+ policy = elevated(self.policy, tool["permission"])
647
+ outcome = self.adapter.execute(tid, name, args, policy=policy, allowed=self.allowed, approved=True)
648
+ outcome.source = "approved" if outcome.source == "live" else outcome.source
649
+ return outcome
650
+
651
+ def _read_board(self, keys) -> dict:
652
+ out = {}
653
+ for key in keys:
654
+ key = str(key)
655
+ if key.endswith("*"):
656
+ out.update(self.board.facts(key[:-1]))
657
+ else:
658
+ out[key] = self.board.read(key)
659
+ return {"facts": out}
660
+
661
+ # -- sub-agents ---------------------------------------------------------------------------------
662
+
663
+ def _child(self, spec: dict, agent_id: str, emit) -> "AgentRunner":
664
+ reads = {e["tool"] for e in self.adapter.catalog if e["permission"] == "read"}
665
+ named = {str(t) for t in spec.get("tools") or ()}
666
+ allowed = (named & set(self.adapter.by_tool)) if named else reads
667
+ allowed |= set(SUBAGENT_LOCAL)
668
+ if self.depth + 1 < self.max_depth:
669
+ allowed |= {SPAWN_AGENTS, AWAIT_AGENTS}
670
+ return AgentRunner(self.conversation_id, store=self.store, gateway=self.gateway, adapter=self.adapter,
671
+ system=self.system, policy=self.policy, capability=self.capability,
672
+ vision_capability=self.vision_capability, model=self.model, max_tokens=self.max_tokens,
673
+ compact_at_tokens=self.compact_at_tokens, max_steps=self.max_steps,
674
+ depth=self.depth + 1, max_depth=self.max_depth, max_parallel=self.max_parallel,
675
+ agent_id=agent_id, role=str(spec.get("role") or "worker")[:40], allowed=allowed,
676
+ parent=self, poll_s=self.poll_s, profile=self.profile, logger=self.log)
677
+
678
+ def _run_child(self, spec: dict, agent_id: str, emit, ctx) -> dict:
679
+ child = self._child(spec, agent_id, emit)
680
+ child._ctx = ctx
681
+ inputs = dict(spec.get("inputs") or {})
682
+ for dep in spec.get("depends_on") or ():
683
+ inputs.setdefault("results_of", {})[dep] = self.board.read(f"result:{dep}")
684
+ brief = self.profile.subagent_brief(child.role, str(spec.get("brief") or ""),
685
+ sorted(t for t in child.allowed if t not in SUBAGENT_LOCAL), inputs)
686
+ child.messages = [{"role": "user", "content": [text_block(brief)]}]
687
+
688
+ def relay(event):
689
+ emit({**event, "agent": event.get("agent") or agent_id})
690
+
691
+ relay({"event": "agent_started", "role": child.role, "brief": str(spec.get("brief") or "")[:300]})
692
+ state, text = "done", ""
693
+ try:
694
+ outcome = child._loop(relay)
695
+ state, text = outcome["state"], outcome.get("text", "")
696
+ except Halted:
697
+ state = "stopped"
698
+ except TurnFailed as exc:
699
+ state, text = "failed", f"the sub-agent's model call failed: {exc}"
700
+ summary = {"agent": agent_id, "role": child.role, "state": state, **_parse_summary(text),
701
+ "model_calls": child._made, "tool_calls": child.tool_calls,
702
+ "charged_micro": child.charged}
703
+ relay({"event": "agent_finished", "state": state, "summary": summary["summary"][:300]})
704
+ if state == "stopped":
705
+ raise Halted()
706
+ return summary
707
+
708
+ def _graph(self, specs: list, emit):
709
+ graph = TaskGraph()
710
+ ids = []
711
+ known = set()
712
+ for n, spec in enumerate(specs):
713
+ agent_id = str(spec.get("id") or f"{spec.get('role') or 'agent'}_{uuid.uuid4().hex[:4]}")
714
+ agent_id = "".join(c for c in agent_id if c.isalnum() or c in "_-")[:40] or f"agent_{n}"
715
+ deps = [str(d) for d in spec.get("depends_on") or () if str(d) in known]
716
+ graph.add(agent_id, (lambda s, a: (lambda ctx: self._run_child(s, a, emit, ctx)))(spec, agent_id),
717
+ depends_on=deps, label=str(spec.get("role") or agent_id))
718
+ known.add(agent_id)
719
+ ids.append(agent_id)
720
+ return graph, ids
721
+
722
+ def _spawn(self, tid: str, args: dict, emit):
723
+ specs = [s for s in (args.get("agents") or []) if isinstance(s, dict) and s.get("brief")]
724
+ if self.depth >= self.max_depth:
725
+ return error_outcome(tid, SPAWN_AGENTS, f"sub-agents may not spawn below depth {self.max_depth}",
726
+ source="local")
727
+ if not specs:
728
+ return error_outcome(tid, SPAWN_AGENTS, "spawn_agents needs agents, each with a role and a brief",
729
+ source="local")
730
+ if len(specs) > MAX_SUBAGENTS:
731
+ return error_outcome(tid, SPAWN_AGENTS, f"at most {MAX_SUBAGENTS} sub-agents at once", source="local")
732
+ graph, ids = self._graph(specs, emit)
733
+
734
+ def tracer(event):
735
+ task = event.get("task")
736
+ if task:
737
+ self.trace.task(self.conversation_id, task, "running" if event["event"] == "task_started"
738
+ else event.get("state", "done"), parent=self.agent_id, label=event.get("label"),
739
+ detail={"error": event.get("error")} if event.get("error") else None)
740
+
741
+ scheduler = Scheduler(graph, max_parallel=self.max_parallel, board=self.board, stagger_first=True,
742
+ on_event=tracer)
743
+ if args.get("wait", True) is False:
744
+ thread = threading.Thread(target=scheduler.run, name=f"spatialagents-agents-{tid}", daemon=True)
745
+ thread.start()
746
+ with self._lock:
747
+ for agent_id in ids:
748
+ self._pending[agent_id] = {"graph": graph, "thread": thread}
749
+ return self.adapter.wrap(tid, SPAWN_AGENTS, {"started": ids, "hint": "await_agents with these ids"})
750
+ result = scheduler.run()
751
+ if self.store.control(self.conversation_id).get("stopped"):
752
+ raise Halted()
753
+ return self.adapter.wrap(tid, SPAWN_AGENTS, self._collect(graph, ids, result))
754
+
755
+ def _collect(self, graph, ids, result=None) -> dict:
756
+ agents = []
757
+ for agent_id in ids:
758
+ task = graph.tasks[agent_id]
759
+ if task.state == "done":
760
+ agents.append(task.result)
761
+ else:
762
+ agents.append({"agent": agent_id, "role": task.label, "state": task.state, "error": task.error})
763
+ out = {"agents": agents}
764
+ if result:
765
+ out["peak_parallel"] = result.get("peak_parallel")
766
+ return out
767
+
768
+ def _await(self, tid: str, args: dict):
769
+ ids = [str(i) for i in args.get("ids") or []]
770
+ timeout = max(1.0, min(float(args.get("timeout_s") or 120), 600.0))
771
+ deadline = time.monotonic() + timeout
772
+ with self._lock:
773
+ known = {i: self._pending.get(i) for i in ids}
774
+ unknown = [i for i, v in known.items() if v is None]
775
+ if unknown:
776
+ return error_outcome(tid, AWAIT_AGENTS, f"no sub-agents {unknown} started with wait=false",
777
+ source="local")
778
+ for entry in {id(v["thread"]): v for v in known.values()}.values():
779
+ entry["thread"].join(max(0.0, deadline - time.monotonic()))
780
+ agents = []
781
+ for agent_id, entry in known.items():
782
+ task = entry["graph"].tasks[agent_id]
783
+ if task.state == "done":
784
+ agents.append(task.result)
785
+ else:
786
+ agents.append({"agent": agent_id, "state": task.state, "error": task.error})
787
+ return self.adapter.wrap(tid, AWAIT_AGENTS, {"agents": agents})
788
+
789
+
790
+ def _images(images) -> list:
791
+ import base64
792
+
793
+ out = []
794
+ for item in images or ():
795
+ if isinstance(item, (bytes, bytearray)):
796
+ out.append((bytes(item), "png"))
797
+ elif isinstance(item, tuple):
798
+ out.append((bytes(item[0]), str(item[1] or "png")))
799
+ elif isinstance(item, dict) and item.get("data"):
800
+ data = item["data"]
801
+ if isinstance(data, str):
802
+ if data.startswith("data:"):
803
+ header, _, data = data.partition(",")
804
+ item = {**item, "format": item.get("format") or header.split("/")[-1].split(";")[0]}
805
+ data = base64.b64decode(data)
806
+ out.append((bytes(data), str(item.get("format") or "png").replace("jpg", "jpeg")))
807
+ return out
808
+
809
+
810
+ def _b64(data: bytes) -> str:
811
+ import base64
812
+
813
+ return base64.b64encode(data).decode("ascii")