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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Multi-agent orchestration: a task graph, a parallel scheduler, and a shared
|
|
2
|
+
blackboard.
|
|
3
|
+
|
|
4
|
+
graph = TaskGraph()
|
|
5
|
+
ref = graph.add("gate:ref", lambda ctx: run(ctx, "lsp_ref"))
|
|
6
|
+
for name in others:
|
|
7
|
+
graph.add(f"gate:{name}", partial(run, project=name), depends_on=[ref])
|
|
8
|
+
Scheduler(graph, max_parallel=4).run()
|
|
9
|
+
|
|
10
|
+
- **Dynamic spawning.** A running task may add tasks (`ctx.spawn(...)`); they
|
|
11
|
+
join the same graph, inherit the parent's depth + 1 and are bounded by
|
|
12
|
+
`max_depth`.
|
|
13
|
+
- **Parallel execution.** Every task whose dependencies are met and whose
|
|
14
|
+
`ready_when(board)` predicate holds runs, up to `max_parallel` at a time.
|
|
15
|
+
A fan-out of model calls that share a cached prefix should let the first
|
|
16
|
+
call warm the cache before the rest fire: `stagger_first=True` holds the
|
|
17
|
+
others until the first task of the graph reports progress (`ctx.warm()`)
|
|
18
|
+
or finishes.
|
|
19
|
+
- **Dependencies.** Hard edges (default) cancel dependents when a task fails;
|
|
20
|
+
soft edges (`soft=[...]`) let them run with the input missing.
|
|
21
|
+
- **Communication.** By reference, never by copying transcripts: `facts` (typed,
|
|
22
|
+
versioned key/value), `artifacts` (ids, never bytes) and a `mailbox` per task.
|
|
23
|
+
A task's return value is published as fact `result:<task id>`.
|
|
24
|
+
|
|
25
|
+
Threads, not asyncio: every task body here calls a product's synchronous
|
|
26
|
+
tools, and the gateway client blocks on its stream; the GIL is released on
|
|
27
|
+
both. The scheduler is plain data plus ~200 lines, so a different runner could
|
|
28
|
+
replace it without touching the engines (Plexora's decision loop, the chat
|
|
29
|
+
runner's sub-agents).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Any, Callable
|
|
40
|
+
|
|
41
|
+
TERMINAL = ("done", "failed", "cancelled", "skipped")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Blackboard:
|
|
45
|
+
"""Shared state for one run. Thread-safe; values are small JSON-able data."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._lock = threading.Condition()
|
|
49
|
+
self._facts: dict[str, tuple[Any, int, str | None]] = {}
|
|
50
|
+
self._mail: dict[str, list[dict]] = {}
|
|
51
|
+
self.artifacts: dict[str, dict] = {}
|
|
52
|
+
self.events: list[dict] = []
|
|
53
|
+
|
|
54
|
+
def post(self, key: str, value: Any, *, by: str | None = None) -> int:
|
|
55
|
+
with self._lock:
|
|
56
|
+
version = self._facts.get(key, (None, 0, None))[1] + 1
|
|
57
|
+
self._facts[key] = (value, version, by)
|
|
58
|
+
self.events.append({"kind": "fact", "key": key, "version": version, "by": by, "at": time.time()})
|
|
59
|
+
self._lock.notify_all()
|
|
60
|
+
return version
|
|
61
|
+
|
|
62
|
+
def read(self, key: str, default=None):
|
|
63
|
+
with self._lock:
|
|
64
|
+
return self._facts.get(key, (default, 0, None))[0]
|
|
65
|
+
|
|
66
|
+
def version(self, key: str) -> int:
|
|
67
|
+
with self._lock:
|
|
68
|
+
return self._facts.get(key, (None, 0, None))[1]
|
|
69
|
+
|
|
70
|
+
def facts(self, prefix: str = "") -> dict:
|
|
71
|
+
with self._lock:
|
|
72
|
+
return {k: v[0] for k, v in self._facts.items() if k.startswith(prefix)}
|
|
73
|
+
|
|
74
|
+
def wait_for(self, key: str, timeout: float | None = None):
|
|
75
|
+
with self._lock:
|
|
76
|
+
self._lock.wait_for(lambda: key in self._facts, timeout=timeout)
|
|
77
|
+
return self._facts.get(key, (None, 0, None))[0]
|
|
78
|
+
|
|
79
|
+
def send(self, to: str, body: dict, *, sender: str | None = None) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._mail.setdefault(to, []).append({"from": sender, "body": body, "at": time.time()})
|
|
82
|
+
self._lock.notify_all()
|
|
83
|
+
|
|
84
|
+
def inbox(self, task_id: str, *, drain: bool = True) -> list[dict]:
|
|
85
|
+
with self._lock:
|
|
86
|
+
mail = self._mail.get(task_id, [])
|
|
87
|
+
if drain:
|
|
88
|
+
self._mail[task_id] = []
|
|
89
|
+
return list(mail)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Task:
|
|
94
|
+
id: str
|
|
95
|
+
fn: Callable[["TaskContext"], Any]
|
|
96
|
+
depends_on: list[str] = field(default_factory=list)
|
|
97
|
+
soft: list[str] = field(default_factory=list)
|
|
98
|
+
ready_when: Callable[[Blackboard], bool] | None = None
|
|
99
|
+
label: str | None = None
|
|
100
|
+
parent: str | None = None
|
|
101
|
+
depth: int = 0
|
|
102
|
+
retries: int = 0
|
|
103
|
+
state: str = "pending"
|
|
104
|
+
result: Any = None
|
|
105
|
+
error: str | None = None
|
|
106
|
+
attempts: int = 0
|
|
107
|
+
started_at: float | None = None
|
|
108
|
+
finished_at: float | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TaskGraph:
|
|
112
|
+
def __init__(self):
|
|
113
|
+
self.tasks: dict[str, Task] = {}
|
|
114
|
+
self._lock = threading.RLock()
|
|
115
|
+
self.order: list[str] = []
|
|
116
|
+
|
|
117
|
+
def add(self, task_id: str | None, fn, *, depends_on=(), soft=(), ready_when=None, label=None,
|
|
118
|
+
parent=None, depth=0, retries=0) -> str:
|
|
119
|
+
task_id = task_id or f"t_{uuid.uuid4().hex[:8]}"
|
|
120
|
+
with self._lock:
|
|
121
|
+
if task_id in self.tasks:
|
|
122
|
+
raise ValueError(f"task {task_id!r} already exists")
|
|
123
|
+
for dep in (*depends_on, *soft):
|
|
124
|
+
if dep not in self.tasks:
|
|
125
|
+
raise ValueError(f"task {task_id!r} depends on unknown task {dep!r}")
|
|
126
|
+
self.tasks[task_id] = Task(task_id, fn, list(depends_on), list(soft), ready_when, label or task_id,
|
|
127
|
+
parent, depth, retries)
|
|
128
|
+
self.order.append(task_id)
|
|
129
|
+
return task_id
|
|
130
|
+
|
|
131
|
+
def ready(self, board: Blackboard) -> list[Task]:
|
|
132
|
+
with self._lock:
|
|
133
|
+
out = []
|
|
134
|
+
for tid in self.order:
|
|
135
|
+
task = self.tasks[tid]
|
|
136
|
+
if task.state != "pending":
|
|
137
|
+
continue
|
|
138
|
+
deps = [self.tasks[d] for d in task.depends_on]
|
|
139
|
+
if any(d.state in ("failed", "cancelled", "skipped") for d in deps):
|
|
140
|
+
task.state, task.error = "cancelled", "a hard dependency did not finish"
|
|
141
|
+
continue
|
|
142
|
+
if not all(d.state == "done" for d in deps):
|
|
143
|
+
continue
|
|
144
|
+
if not all(self.tasks[s].state in TERMINAL for s in task.soft):
|
|
145
|
+
continue
|
|
146
|
+
if task.ready_when is not None and not task.ready_when(board):
|
|
147
|
+
continue
|
|
148
|
+
out.append(task)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
def pending(self) -> list[Task]:
|
|
152
|
+
with self._lock:
|
|
153
|
+
return [t for t in self.tasks.values() if t.state not in TERMINAL]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TaskContext:
|
|
157
|
+
def __init__(self, scheduler: "Scheduler", task: Task):
|
|
158
|
+
self._scheduler = scheduler
|
|
159
|
+
self.task = task
|
|
160
|
+
self.board = scheduler.board
|
|
161
|
+
|
|
162
|
+
def spawn(self, fn, *, task_id=None, depends_on=(), soft=(), ready_when=None, label=None, retries=0) -> str:
|
|
163
|
+
if self.task.depth + 1 > self._scheduler.max_depth:
|
|
164
|
+
raise RuntimeError(f"spawning below depth {self._scheduler.max_depth} is not allowed")
|
|
165
|
+
new = self._scheduler.graph.add(task_id, fn, depends_on=depends_on, soft=soft, ready_when=ready_when,
|
|
166
|
+
label=label, parent=self.task.id, depth=self.task.depth + 1,
|
|
167
|
+
retries=retries)
|
|
168
|
+
self._scheduler._wake()
|
|
169
|
+
return new
|
|
170
|
+
|
|
171
|
+
def warm(self) -> None:
|
|
172
|
+
"""Say the shared prefix is now cached; staggered siblings may start."""
|
|
173
|
+
self._scheduler._warmed.set()
|
|
174
|
+
|
|
175
|
+
def cancelled(self) -> bool:
|
|
176
|
+
return self._scheduler.cancel_event.is_set()
|
|
177
|
+
|
|
178
|
+
def result_of(self, task_id: str):
|
|
179
|
+
return self.board.read(f"result:{task_id}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Scheduler:
|
|
183
|
+
def __init__(self, graph: TaskGraph, *, max_parallel: int = 4, max_depth: int = 2,
|
|
184
|
+
board: Blackboard | None = None, stagger_first: bool = False, on_event=None,
|
|
185
|
+
cancel: threading.Event | None = None):
|
|
186
|
+
self.graph = graph
|
|
187
|
+
self.max_parallel = max(1, int(max_parallel))
|
|
188
|
+
self.max_depth = max_depth
|
|
189
|
+
self.board = board or Blackboard()
|
|
190
|
+
self.stagger_first = stagger_first
|
|
191
|
+
self.on_event = on_event or (lambda event: None)
|
|
192
|
+
self.cancel_event = cancel or threading.Event()
|
|
193
|
+
self._warmed = threading.Event()
|
|
194
|
+
self._wakeup = threading.Event()
|
|
195
|
+
self.peak = 0
|
|
196
|
+
|
|
197
|
+
def _wake(self):
|
|
198
|
+
self._wakeup.set()
|
|
199
|
+
|
|
200
|
+
def _execute(self, task: Task):
|
|
201
|
+
ctx = TaskContext(self, task)
|
|
202
|
+
while True:
|
|
203
|
+
task.attempts += 1
|
|
204
|
+
try:
|
|
205
|
+
return task.fn(ctx)
|
|
206
|
+
except Exception:
|
|
207
|
+
if task.attempts > task.retries or self.cancel_event.is_set():
|
|
208
|
+
raise
|
|
209
|
+
|
|
210
|
+
def run(self) -> dict:
|
|
211
|
+
running: dict[Future, Task] = {}
|
|
212
|
+
launched_any = False
|
|
213
|
+
with ThreadPoolExecutor(max_workers=self.max_parallel, thread_name_prefix="spatialagents-agent") as pool:
|
|
214
|
+
while True:
|
|
215
|
+
if self.cancel_event.is_set():
|
|
216
|
+
for task in self.graph.pending():
|
|
217
|
+
if task.state == "pending":
|
|
218
|
+
task.state = "cancelled"
|
|
219
|
+
ready = [] if self.cancel_event.is_set() else self.graph.ready(self.board)
|
|
220
|
+
hold = bool(self.stagger_first and launched_any and not self._warmed.is_set() and running)
|
|
221
|
+
for task in ready:
|
|
222
|
+
if len(running) >= self.max_parallel or hold:
|
|
223
|
+
break
|
|
224
|
+
task.state, task.started_at = "running", time.time()
|
|
225
|
+
self.on_event({"event": "task_started", "task": task.id, "label": task.label,
|
|
226
|
+
"parent": task.parent})
|
|
227
|
+
running[pool.submit(self._execute, task)] = task
|
|
228
|
+
self.peak = max(self.peak, len(running))
|
|
229
|
+
if self.stagger_first and not launched_any:
|
|
230
|
+
launched_any = True
|
|
231
|
+
hold = not self._warmed.is_set()
|
|
232
|
+
if not running:
|
|
233
|
+
if not self.graph.ready(self.board):
|
|
234
|
+
stuck = [t for t in self.graph.pending() if t.state == "pending"]
|
|
235
|
+
for task in stuck:
|
|
236
|
+
task.state, task.error = "skipped", "never became ready"
|
|
237
|
+
break
|
|
238
|
+
continue
|
|
239
|
+
done, _ = wait(list(running), timeout=0.2, return_when=FIRST_COMPLETED)
|
|
240
|
+
self._wakeup.clear()
|
|
241
|
+
for future in done:
|
|
242
|
+
task = running.pop(future)
|
|
243
|
+
task.finished_at = time.time()
|
|
244
|
+
try:
|
|
245
|
+
task.result = future.result()
|
|
246
|
+
task.state = "done"
|
|
247
|
+
self.board.post(f"result:{task.id}", task.result, by=task.id)
|
|
248
|
+
except Exception as exc: # noqa: BLE001 -- recorded on the task
|
|
249
|
+
task.state, task.error = "failed", f"{type(exc).__name__}: {exc}"
|
|
250
|
+
if self.stagger_first:
|
|
251
|
+
self._warmed.set()
|
|
252
|
+
self.on_event({"event": "task_finished", "task": task.id, "state": task.state,
|
|
253
|
+
"error": task.error})
|
|
254
|
+
tasks = self.graph.tasks.values()
|
|
255
|
+
return {"done": sum(t.state == "done" for t in tasks), "failed": sum(t.state == "failed" for t in tasks),
|
|
256
|
+
"cancelled": sum(t.state in ("cancelled", "skipped") for t in tasks), "peak_parallel": self.peak,
|
|
257
|
+
"tasks": {t.id: {"state": t.state, "error": t.error, "parent": t.parent,
|
|
258
|
+
"seconds": round((t.finished_at or 0) - (t.started_at or 0), 3)
|
|
259
|
+
if t.started_at else None} for t in tasks}}
|