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,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}}