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/runner.py
ADDED
|
@@ -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")
|