@steerable/runtime-win32-x64 0.10.2 → 0.11.0
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.
- package/package.json +1 -1
- package/python-runtime/manifest.json +1 -1
- package/python-runtime/python/Lib/site-packages/cffi-2.1.1.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/idna-3.20.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/jsonschema-4.26.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/mcp-1.30.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/python_dotenv-1.2.4.dist-info/RECORD +1 -1
- package/python-runtime/python/Lib/site-packages/pywin32-312.dist-info/RECORD +2 -2
- package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.10.2.dist-info → steerable_agent_harness-0.11.0.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.10.2.dist-info → steerable_agent_harness-0.11.0.dist-info}/RECORD +7 -7
- package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.10.2.dist-info → steerable_agent_protocol-0.11.0.dist-info}/METADATA +1 -1
- package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.11.0.dist-info/RECORD +11 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/__init__.py +12 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/orchestration.py +374 -35
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/peer.py +192 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/pool.py +96 -2
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/subagent.py +27 -8
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/team_board.py +229 -0
- package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/tree.py +155 -0
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.10.2.dist-info → steerable_agent_runtime-0.11.0.dist-info}/METADATA +32 -1
- package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.10.2.dist-info → steerable_agent_runtime-0.11.0.dist-info}/RECORD +17 -11
- package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +49 -1
- package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/METADATA +25 -1
- package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/RECORD +12 -12
- package/python-runtime/python/Lib/site-packages/uvicorn-0.54.0.dist-info/RECORD +1 -1
- package/python-runtime/python/Scripts/cffi-gen-src.exe +0 -0
- package/python-runtime/python/Scripts/dotenv.exe +0 -0
- package/python-runtime/python/Scripts/httpx.exe +0 -0
- package/python-runtime/python/Scripts/idna.exe +0 -0
- package/python-runtime/python/Scripts/jsonschema.exe +0 -0
- package/python-runtime/python/Scripts/mcp.exe +0 -0
- package/python-runtime/python/Scripts/pip.exe +0 -0
- package/python-runtime/python/Scripts/pip3.12.exe +0 -0
- package/python-runtime/python/Scripts/pip3.exe +0 -0
- package/python-runtime/python/Scripts/pywin32_postinstall.exe +0 -0
- package/python-runtime/python/Scripts/pywin32_testall.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar-acp.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar-headless.exe +0 -0
- package/python-runtime/python/Scripts/steerable-sidecar.exe +0 -0
- package/python-runtime/python/Scripts/uvicorn.exe +0 -0
- package/runtime.json +67 -55
- package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.2.dist-info/RECORD +0 -11
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.10.2.dist-info → steerable_agent_harness-0.11.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.10.2.dist-info → steerable_agent_harness-0.11.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.10.2.dist-info → steerable_agent_protocol-0.11.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_protocol-0.10.2.dist-info → steerable_agent_protocol-0.11.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.10.2.dist-info → steerable_agent_runtime-0.11.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_runtime-0.10.2.dist-info → steerable_agent_runtime-0.11.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/entry_points.txt +0 -0
|
@@ -45,6 +45,7 @@ from .hooks import ChainHooks, CompletionAction, CompletionDraft, NoopHooks
|
|
|
45
45
|
from .history import completed_turn_prefix
|
|
46
46
|
from .llm import LLMMessage, LLMProvider
|
|
47
47
|
from .loop import CoreLoop, LoopConfig, LoopContext, LoopHooks, ToolExecutor
|
|
48
|
+
from .peer import PeerToolsExecutor, peer_schemas, send_tool_descriptor, tool_schema
|
|
48
49
|
from .pool import (
|
|
49
50
|
AgentPool,
|
|
50
51
|
ChildOutcome,
|
|
@@ -55,6 +56,8 @@ from .pool import (
|
|
|
55
56
|
from .registry import ChildRegistry, default_record_id
|
|
56
57
|
from .resume import load_history_transcript
|
|
57
58
|
from .subagent import FilteredToolsExecutor, _schema_name
|
|
59
|
+
from .team_board import TeamTaskBoard
|
|
60
|
+
from .tree import AgentTree, TreeMember
|
|
58
61
|
|
|
59
62
|
logger = logging.getLogger(__name__)
|
|
60
63
|
|
|
@@ -66,7 +69,10 @@ __all__ = [
|
|
|
66
69
|
"OrchestrationBudgetExceeded",
|
|
67
70
|
"OrchestrationConfig",
|
|
68
71
|
"OrchestrationExecutor",
|
|
72
|
+
"PeerToolsExecutor",
|
|
69
73
|
"orchestration_tool_descriptors",
|
|
74
|
+
"send_tool_descriptor",
|
|
75
|
+
"team_tool_descriptors",
|
|
70
76
|
]
|
|
71
77
|
|
|
72
78
|
#: Terminal status of a run stopped by ``max_rounds`` or the token budget.
|
|
@@ -159,20 +165,7 @@ def orchestration_tool_descriptors(
|
|
|
159
165
|
"""OpenAI tool schemas for the four orchestration tools."""
|
|
160
166
|
config = config or OrchestrationConfig()
|
|
161
167
|
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
"type": "function",
|
|
165
|
-
"function": {
|
|
166
|
-
"name": name,
|
|
167
|
-
"description": description,
|
|
168
|
-
"parameters": {
|
|
169
|
-
"type": "object",
|
|
170
|
-
"properties": properties,
|
|
171
|
-
"required": required,
|
|
172
|
-
"additionalProperties": False,
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
}
|
|
168
|
+
fn = tool_schema
|
|
176
169
|
|
|
177
170
|
return [
|
|
178
171
|
fn(
|
|
@@ -234,18 +227,7 @@ def orchestration_tool_descriptors(
|
|
|
234
227
|
},
|
|
235
228
|
["task"],
|
|
236
229
|
),
|
|
237
|
-
|
|
238
|
-
config.send_tool,
|
|
239
|
-
"Send a message to a sub-agent. A running child receives it at "
|
|
240
|
-
"its next round boundary (steer); a finished or interrupted "
|
|
241
|
-
"child is resumed with the message as a follow-up turn, keeping "
|
|
242
|
-
"its prior context. Closed children reject sends.",
|
|
243
|
-
{
|
|
244
|
-
"childId": {"type": "string"},
|
|
245
|
-
"message": {"type": "string"},
|
|
246
|
-
},
|
|
247
|
-
["childId", "message"],
|
|
248
|
-
),
|
|
230
|
+
send_tool_descriptor(config),
|
|
249
231
|
fn(
|
|
250
232
|
config.wait_tool,
|
|
251
233
|
"Wait for the first sub-agent to finish and return its outcome. "
|
|
@@ -274,9 +256,11 @@ def orchestration_tool_descriptors(
|
|
|
274
256
|
),
|
|
275
257
|
fn(
|
|
276
258
|
config.list_tool,
|
|
277
|
-
"List this
|
|
259
|
+
"List this job's agents with their status (running / "
|
|
278
260
|
"completed / error / cancelled / interrupted / closed), task, "
|
|
279
|
-
"and a preview of finished answers."
|
|
261
|
+
"and a preview of finished answers. `members` names every live "
|
|
262
|
+
"agent in this job with the lineage id to message (`self` is "
|
|
263
|
+
"yours), which is how you address a sibling or the root.",
|
|
280
264
|
{},
|
|
281
265
|
[],
|
|
282
266
|
),
|
|
@@ -292,6 +276,240 @@ def orchestration_tool_descriptors(
|
|
|
292
276
|
]
|
|
293
277
|
|
|
294
278
|
|
|
279
|
+
def team_tool_descriptors(
|
|
280
|
+
config: OrchestrationConfig | None = None,
|
|
281
|
+
) -> list[dict[str, Any]]:
|
|
282
|
+
"""OpenAI tool schemas for the shared task board (R8).
|
|
283
|
+
|
|
284
|
+
The board moves the plan out of any one context: the agent that split the
|
|
285
|
+
job writes items down, workers claim them, and anyone can see what is left.
|
|
286
|
+
The tools are only advertised when a board is wired, so a host that did not
|
|
287
|
+
opt in never sees them.
|
|
288
|
+
"""
|
|
289
|
+
config = config or OrchestrationConfig()
|
|
290
|
+
|
|
291
|
+
def fn(name: str, description: str, properties: dict, required: list[str]) -> dict:
|
|
292
|
+
return {
|
|
293
|
+
"type": "function",
|
|
294
|
+
"function": {
|
|
295
|
+
"name": name,
|
|
296
|
+
"description": description,
|
|
297
|
+
"parameters": {
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": properties,
|
|
300
|
+
"required": required,
|
|
301
|
+
"additionalProperties": False,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return [
|
|
307
|
+
fn(
|
|
308
|
+
config.task_create_tool,
|
|
309
|
+
"Add a unit of work to the shared board so any agent in this "
|
|
310
|
+
"job can see it. Use this when splitting work across agents: "
|
|
311
|
+
"write the items once here instead of repeating them in every "
|
|
312
|
+
"task you hand out. Returns the task id.",
|
|
313
|
+
{
|
|
314
|
+
"title": {
|
|
315
|
+
"type": "string",
|
|
316
|
+
"description": "One line naming the work.",
|
|
317
|
+
},
|
|
318
|
+
"detail": {
|
|
319
|
+
"type": "string",
|
|
320
|
+
"description": (
|
|
321
|
+
"Optional specifics the claimant needs — files, "
|
|
322
|
+
"acceptance, constraints."
|
|
323
|
+
),
|
|
324
|
+
},
|
|
325
|
+
"dependsOn": {
|
|
326
|
+
"type": "array",
|
|
327
|
+
"items": {"type": "string"},
|
|
328
|
+
"description": (
|
|
329
|
+
"Task ids that must finish before this one can be "
|
|
330
|
+
"claimed. Ids come from earlier team_task_create "
|
|
331
|
+
"results."
|
|
332
|
+
),
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
["title"],
|
|
336
|
+
),
|
|
337
|
+
fn(
|
|
338
|
+
config.task_claim_tool,
|
|
339
|
+
"Claim a task from the shared board before working on it. "
|
|
340
|
+
"Claiming is what stops two agents doing the same item: it "
|
|
341
|
+
"fails if the task is taken, done, or still blocked by a "
|
|
342
|
+
"dependency. Omit taskId to claim the first ready item. A "
|
|
343
|
+
"refusal lists what is still claimable.",
|
|
344
|
+
{
|
|
345
|
+
"taskId": {
|
|
346
|
+
"type": "string",
|
|
347
|
+
"description": "Exact task id; omit to take the first ready one.",
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
[],
|
|
351
|
+
),
|
|
352
|
+
fn(
|
|
353
|
+
config.task_complete_tool,
|
|
354
|
+
"Mark a task you claimed as done, optionally recording what you "
|
|
355
|
+
"found or changed. Completing unblocks tasks that depend on it.",
|
|
356
|
+
{
|
|
357
|
+
"taskId": {"type": "string"},
|
|
358
|
+
"note": {
|
|
359
|
+
"type": "string",
|
|
360
|
+
"description": "Optional outcome note for whoever reads the board.",
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
["taskId"],
|
|
364
|
+
),
|
|
365
|
+
fn(
|
|
366
|
+
config.task_list_tool,
|
|
367
|
+
"List the shared board: every item with its status, owner, and "
|
|
368
|
+
"dependencies, plus a per-status count.",
|
|
369
|
+
{},
|
|
370
|
+
[],
|
|
371
|
+
),
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class TeamToolExecutor:
|
|
376
|
+
"""ToolExecutor decorator: the four shared-board tools.
|
|
377
|
+
|
|
378
|
+
Its own decorator rather than part of ``OrchestrationExecutor`` because
|
|
379
|
+
the board is orthogonal to the spawn family: a leaf child has no
|
|
380
|
+
``agent_spawn`` but must still see the plan and claim from it. Keeping the
|
|
381
|
+
two apart is what lets that child be a full participant without gaining
|
|
382
|
+
the ability to spawn.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
def __init__(
|
|
386
|
+
self,
|
|
387
|
+
inner: ToolExecutor,
|
|
388
|
+
board: TeamTaskBoard,
|
|
389
|
+
config: OrchestrationConfig,
|
|
390
|
+
*,
|
|
391
|
+
lineage: str,
|
|
392
|
+
) -> None:
|
|
393
|
+
self._inner = inner
|
|
394
|
+
self._board = board
|
|
395
|
+
self._config = config
|
|
396
|
+
#: Who claims and completes — the caller's lineage id, so the board
|
|
397
|
+
#: answers "who is doing this" without a second registry.
|
|
398
|
+
self._lineage = lineage
|
|
399
|
+
|
|
400
|
+
async def execute(self, call: ToolCall, ctx: LoopContext) -> ToolResult:
|
|
401
|
+
if call.name == self._config.task_create_tool:
|
|
402
|
+
return self._create(call)
|
|
403
|
+
if call.name == self._config.task_claim_tool:
|
|
404
|
+
return self._claim(call)
|
|
405
|
+
if call.name == self._config.task_complete_tool:
|
|
406
|
+
return self._complete(call)
|
|
407
|
+
if call.name == self._config.task_list_tool:
|
|
408
|
+
return self._list()
|
|
409
|
+
return await self._inner.execute(call, ctx)
|
|
410
|
+
|
|
411
|
+
def _create(self, call: ToolCall) -> ToolResult:
|
|
412
|
+
title = str(call.arguments.get("title") or "").strip()
|
|
413
|
+
if not title:
|
|
414
|
+
return ToolResult(success=False, error="empty title")
|
|
415
|
+
raw_deps = call.arguments.get("dependsOn")
|
|
416
|
+
depends_on = (
|
|
417
|
+
tuple(str(dep).strip() for dep in raw_deps if str(dep).strip())
|
|
418
|
+
if isinstance(raw_deps, list)
|
|
419
|
+
else ()
|
|
420
|
+
)
|
|
421
|
+
detail = str(call.arguments.get("detail") or "").strip() or None
|
|
422
|
+
try:
|
|
423
|
+
item = self._board.create(
|
|
424
|
+
title=title,
|
|
425
|
+
detail=detail,
|
|
426
|
+
depends_on=depends_on,
|
|
427
|
+
created_by=self._lineage,
|
|
428
|
+
)
|
|
429
|
+
except KeyError as exc:
|
|
430
|
+
return ToolResult(
|
|
431
|
+
success=False, error=str(exc.args[0]), needsFollowup=True
|
|
432
|
+
)
|
|
433
|
+
return ToolResult(
|
|
434
|
+
success=True, message=json.dumps(item.to_wire()), data={"taskId": item.id}
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def _claim(self, call: ToolCall) -> ToolResult:
|
|
438
|
+
task_id = str(call.arguments.get("taskId") or "").strip()
|
|
439
|
+
if not task_id:
|
|
440
|
+
ready = self._board.ready()
|
|
441
|
+
if not ready:
|
|
442
|
+
return ToolResult(
|
|
443
|
+
success=False,
|
|
444
|
+
error=(
|
|
445
|
+
"no_ready_task: nothing is unclaimed and unblocked; "
|
|
446
|
+
f"board: {json.dumps(self._board.summary())}"
|
|
447
|
+
),
|
|
448
|
+
needsFollowup=True,
|
|
449
|
+
)
|
|
450
|
+
task_id = ready[0].id
|
|
451
|
+
try:
|
|
452
|
+
item = self._board.claim(task_id, owner=self._lineage)
|
|
453
|
+
except (KeyError, ValueError) as exc:
|
|
454
|
+
return ToolResult(
|
|
455
|
+
success=False,
|
|
456
|
+
error=f"claim_refused: {exc.args[0]}",
|
|
457
|
+
needsFollowup=True,
|
|
458
|
+
)
|
|
459
|
+
return ToolResult(
|
|
460
|
+
success=True,
|
|
461
|
+
message=json.dumps(item.to_wire()),
|
|
462
|
+
data={"taskId": item.id, "status": item.status},
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def _complete(self, call: ToolCall) -> ToolResult:
|
|
466
|
+
task_id = str(call.arguments.get("taskId") or "").strip()
|
|
467
|
+
if not task_id:
|
|
468
|
+
return ToolResult(success=False, error="empty taskId")
|
|
469
|
+
note = str(call.arguments.get("note") or "").strip() or None
|
|
470
|
+
try:
|
|
471
|
+
item = self._board.complete(task_id, owner=self._lineage, note=note)
|
|
472
|
+
except (KeyError, ValueError) as exc:
|
|
473
|
+
return ToolResult(
|
|
474
|
+
success=False,
|
|
475
|
+
error=f"complete_refused: {exc.args[0]}",
|
|
476
|
+
needsFollowup=True,
|
|
477
|
+
)
|
|
478
|
+
return ToolResult(
|
|
479
|
+
success=True,
|
|
480
|
+
message=json.dumps(item.to_wire()),
|
|
481
|
+
data={"taskId": item.id, "status": item.status},
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def _list(self) -> ToolResult:
|
|
485
|
+
payload = {
|
|
486
|
+
"tasks": [item.to_wire() for item in self._board.items()],
|
|
487
|
+
"summary": self._board.summary(),
|
|
488
|
+
}
|
|
489
|
+
return ToolResult(
|
|
490
|
+
success=True,
|
|
491
|
+
message=json.dumps(payload),
|
|
492
|
+
data={"count": len(self._board.items())},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def dedup_exempt(self, call: ToolCall) -> bool:
|
|
496
|
+
# Claim is stateful: the same arguments twice must observe the second
|
|
497
|
+
# refusal, not a cached first success.
|
|
498
|
+
if call.name in self._config.team_tool_names:
|
|
499
|
+
return True
|
|
500
|
+
inner_exempt = getattr(self._inner, "dedup_exempt", None)
|
|
501
|
+
return bool(inner_exempt and inner_exempt(call))
|
|
502
|
+
|
|
503
|
+
def concurrency_safe(self, call: ToolCall) -> bool:
|
|
504
|
+
# Every board call mutates or reads shared state; running two claim
|
|
505
|
+
# attempts concurrently would defeat the exclusivity the board exists
|
|
506
|
+
# for.
|
|
507
|
+
if call.name in self._config.team_tool_names:
|
|
508
|
+
return False
|
|
509
|
+
inner_safe = getattr(self._inner, "concurrency_safe", None)
|
|
510
|
+
return bool(inner_safe and inner_safe(call))
|
|
511
|
+
|
|
512
|
+
|
|
295
513
|
class OrchestrationExecutor:
|
|
296
514
|
"""ToolExecutor decorator: the six orchestration tools drive an AgentPool.
|
|
297
515
|
|
|
@@ -318,9 +536,14 @@ class OrchestrationExecutor:
|
|
|
318
536
|
budget: TreeBudget | None = None,
|
|
319
537
|
parent_route: Callable[[], tuple[str | None, str | None]] | None = None,
|
|
320
538
|
provider_factory: Callable[[str | None, str | None], LLMProvider] | None = None,
|
|
539
|
+
tree: AgentTree | None = None,
|
|
540
|
+
team_board: TeamTaskBoard | None = None,
|
|
321
541
|
_depth: int = 0,
|
|
322
542
|
_lineage: str = "0",
|
|
323
543
|
) -> None:
|
|
544
|
+
#: The executor children are built from, and peers' tool calls land
|
|
545
|
+
#: on. Kept unwrapped so the board decorator below is applied exactly
|
|
546
|
+
#: once per agent.
|
|
324
547
|
self._inner = inner
|
|
325
548
|
self._provider = provider
|
|
326
549
|
self._config = config or OrchestrationConfig()
|
|
@@ -351,6 +574,23 @@ class OrchestrationExecutor:
|
|
|
351
574
|
#: fork needs it; without one, `agent_spawn` cannot change the route
|
|
352
575
|
#: and says so instead of silently ignoring the request.
|
|
353
576
|
self._provider_factory = provider_factory
|
|
577
|
+
#: Tree-wide addressing (R8). Shared down the whole spawn tree so a
|
|
578
|
+
#: child can message a sibling, its parent, or its own children. Each
|
|
579
|
+
#: pool registers only the agents it owns; delivery goes through the
|
|
580
|
+
#: owner. ``None`` keeps the pre-R8 reach: this pool's children only.
|
|
581
|
+
self._tree = tree
|
|
582
|
+
#: The shared board (R8). One per tree, so every member reads and
|
|
583
|
+
#: writes the same plan. ``None`` leaves the team tools unadvertised.
|
|
584
|
+
self._team_board = team_board
|
|
585
|
+
#: The board decorator, built once so this agent's own calls and its
|
|
586
|
+
#: children's share one implementation.
|
|
587
|
+
self._team = (
|
|
588
|
+
TeamToolExecutor(
|
|
589
|
+
self._inner, team_board, self._config, lineage=_lineage
|
|
590
|
+
)
|
|
591
|
+
if team_board is not None
|
|
592
|
+
else None
|
|
593
|
+
)
|
|
354
594
|
#: The tree's shared token limit; ``None`` keeps per-run accounting.
|
|
355
595
|
self._budget = budget
|
|
356
596
|
self._depth = _depth
|
|
@@ -366,6 +606,7 @@ class OrchestrationExecutor:
|
|
|
366
606
|
budget=budget,
|
|
367
607
|
# Needed to write a fork seed's provenance into the child's record.
|
|
368
608
|
history_store=history_store,
|
|
609
|
+
tree=tree,
|
|
369
610
|
)
|
|
370
611
|
|
|
371
612
|
@property
|
|
@@ -441,10 +682,33 @@ class OrchestrationExecutor:
|
|
|
441
682
|
own_loop[0].history.projection if own_loop else []
|
|
442
683
|
),
|
|
443
684
|
budget=self._budget,
|
|
685
|
+
# Same tree and board: a grandchild is as much a member of the
|
|
686
|
+
# job as its parent, and the plan it reads must be the one the
|
|
687
|
+
# root wrote.
|
|
688
|
+
tree=self._tree,
|
|
689
|
+
team_board=self._team_board,
|
|
444
690
|
_depth=child_depth,
|
|
445
691
|
_lineage=child_id,
|
|
446
692
|
)
|
|
447
693
|
schemas = [*schemas, *orchestration_tool_descriptors(self._config)]
|
|
694
|
+
elif self._tree is not None:
|
|
695
|
+
# A leaf cannot delegate, but it is still a member of the job:
|
|
696
|
+
# without a send it could only report through the completion
|
|
697
|
+
# notice, which carries no reply path. Messaging is a tree
|
|
698
|
+
# property, not a depth permission.
|
|
699
|
+
base = PeerToolsExecutor(
|
|
700
|
+
base, self._tree, self._config, lineage=child_id
|
|
701
|
+
)
|
|
702
|
+
schemas = [*schemas, *peer_schemas(self._config)]
|
|
703
|
+
# The board is advertised to every child regardless of nesting: a
|
|
704
|
+
# worker that cannot spawn anyone still needs to see the plan and
|
|
705
|
+
# claim from it, which is why the board is not part of the
|
|
706
|
+
# orchestration family.
|
|
707
|
+
if self._team_board is not None:
|
|
708
|
+
base = TeamToolExecutor(
|
|
709
|
+
base, self._team_board, self._config, lineage=child_id
|
|
710
|
+
)
|
|
711
|
+
schemas = [*schemas, *team_tool_descriptors(self._config)]
|
|
448
712
|
wrap_up = ChildBudgetWrapUp()
|
|
449
713
|
loop = CoreLoop(
|
|
450
714
|
child_provider,
|
|
@@ -477,6 +741,20 @@ class OrchestrationExecutor:
|
|
|
477
741
|
return await self._list(call)
|
|
478
742
|
if call.name == self._config.interrupt_tool:
|
|
479
743
|
return await self._interrupt(call)
|
|
744
|
+
if call.name in self._config.team_tool_names:
|
|
745
|
+
# The board layer answers only when a board was wired; otherwise
|
|
746
|
+
# this agent has no plan to read and says so instead of letting the
|
|
747
|
+
# call fall through to an executor that does not know the name.
|
|
748
|
+
if self._team_board is None:
|
|
749
|
+
return ToolResult(
|
|
750
|
+
success=False,
|
|
751
|
+
error=(
|
|
752
|
+
"team_board_unavailable: this turn has no shared task "
|
|
753
|
+
"board; work from the tasks you were given"
|
|
754
|
+
),
|
|
755
|
+
needsFollowup=True,
|
|
756
|
+
)
|
|
757
|
+
return await self._team.execute(call, ctx)
|
|
480
758
|
return await self._inner.execute(call, ctx)
|
|
481
759
|
|
|
482
760
|
async def _spawn(self, call: ToolCall) -> ToolResult:
|
|
@@ -641,9 +919,14 @@ class OrchestrationExecutor:
|
|
|
641
919
|
return ToolResult(success=False, error="empty message")
|
|
642
920
|
outcome = self._pool.send(child_id, message)
|
|
643
921
|
if outcome.startswith("error:"):
|
|
644
|
-
# The pool only knows the children of this turn.
|
|
645
|
-
#
|
|
646
|
-
#
|
|
922
|
+
# The pool only knows the children of this turn. Two other
|
|
923
|
+
# destinations are legitimate, and both are tried before refusing:
|
|
924
|
+
# a tree member that some other executor owns (a sibling, or this
|
|
925
|
+
# agent's own parent), then a child from an earlier turn, which is
|
|
926
|
+
# rebuilt from its durable row.
|
|
927
|
+
peer = await self._send_to_tree(child_id, message)
|
|
928
|
+
if peer is not None:
|
|
929
|
+
return peer
|
|
647
930
|
resumed = await self._resume_from_registry(child_id, message)
|
|
648
931
|
if resumed is not None:
|
|
649
932
|
return resumed
|
|
@@ -658,6 +941,42 @@ class OrchestrationExecutor:
|
|
|
658
941
|
data={"childId": child_id, "delivery": outcome},
|
|
659
942
|
)
|
|
660
943
|
|
|
944
|
+
async def _send_to_tree(self, target: str, message: str) -> ToolResult | None:
|
|
945
|
+
"""Deliver to a member this pool does not own, if the tree has it.
|
|
946
|
+
|
|
947
|
+
Returns ``None`` when there is no tree, or the target is not a member —
|
|
948
|
+
the caller then falls through to the durable-index resume, so the
|
|
949
|
+
refusals stay in one place with one wording.
|
|
950
|
+
|
|
951
|
+
Delivery goes through the owner, which may steer a running agent or
|
|
952
|
+
resume a settled one. A resume can take a while (the owner waits for
|
|
953
|
+
the first step), so the verdict reports what happened rather than
|
|
954
|
+
pretending the message was merely queued.
|
|
955
|
+
"""
|
|
956
|
+
if self._tree is None:
|
|
957
|
+
return None
|
|
958
|
+
member = self._tree.get(target)
|
|
959
|
+
if member is None:
|
|
960
|
+
return None
|
|
961
|
+
verdict = await self._tree.send(self._lineage, target, message)
|
|
962
|
+
if verdict.startswith("error:"):
|
|
963
|
+
return ToolResult(
|
|
964
|
+
success=False,
|
|
965
|
+
error=verdict[len("error:"):],
|
|
966
|
+
needsFollowup=True,
|
|
967
|
+
)
|
|
968
|
+
return ToolResult(
|
|
969
|
+
success=True,
|
|
970
|
+
message=json.dumps(
|
|
971
|
+
{
|
|
972
|
+
"childId": target,
|
|
973
|
+
"delivery": verdict,
|
|
974
|
+
"depth": member.depth,
|
|
975
|
+
}
|
|
976
|
+
),
|
|
977
|
+
data={"childId": target, "delivery": verdict},
|
|
978
|
+
)
|
|
979
|
+
|
|
661
980
|
async def _resume_from_registry(
|
|
662
981
|
self, child_id: str, message: str
|
|
663
982
|
) -> ToolResult | None:
|
|
@@ -746,12 +1065,18 @@ class OrchestrationExecutor:
|
|
|
746
1065
|
return [*transcript, LLMMessage.text_of("user", message)]
|
|
747
1066
|
|
|
748
1067
|
async def _list(self, call: ToolCall) -> ToolResult:
|
|
749
|
-
"""Every child of this session,
|
|
1068
|
+
"""Every child of this session, plus every agent this one can message.
|
|
750
1069
|
|
|
751
1070
|
The pool is per turn, so on its own it answers "what is running"; the
|
|
752
1071
|
durable index answers "what did this session spawn". Merging both is
|
|
753
1072
|
what makes the tool useful in a later turn, which is the whole point
|
|
754
1073
|
of R1 — a child that finished three turns ago is still listed.
|
|
1074
|
+
|
|
1075
|
+
``members`` is the second answer, and it is the one ``agent_send``
|
|
1076
|
+
needs: it names every live address in this job — this agent's children,
|
|
1077
|
+
its siblings, and the root — because a worker that has to route a
|
|
1078
|
+
finding sideways cannot look up an id it was never told. Both keys are
|
|
1079
|
+
present so a host reading the older one keeps working.
|
|
755
1080
|
"""
|
|
756
1081
|
entries = self._pool.list()
|
|
757
1082
|
seen = {entry["childId"] for entry in entries}
|
|
@@ -773,9 +1098,15 @@ class OrchestrationExecutor:
|
|
|
773
1098
|
**({"resumable": True} if view.resumable else {}),
|
|
774
1099
|
}
|
|
775
1100
|
)
|
|
1101
|
+
payload: dict[str, Any] = {"children": entries}
|
|
1102
|
+
if self._tree is not None:
|
|
1103
|
+
payload["members"] = [
|
|
1104
|
+
member.to_wire() for member in self._tree.members()
|
|
1105
|
+
]
|
|
1106
|
+
payload["self"] = self._lineage
|
|
776
1107
|
return ToolResult(
|
|
777
1108
|
success=True,
|
|
778
|
-
message=json.dumps(
|
|
1109
|
+
message=json.dumps(payload),
|
|
779
1110
|
data={"count": len(entries)},
|
|
780
1111
|
)
|
|
781
1112
|
|
|
@@ -888,8 +1219,12 @@ class OrchestrationExecutor:
|
|
|
888
1219
|
# a second wait observes the resumed run). The loop's same-turn
|
|
889
1220
|
# duplicate_call guard would break legitimate sequences like
|
|
890
1221
|
# wait → send → wait, so the family opts out; inner tools keep the
|
|
891
|
-
# guard unless the inner executor exempts them.
|
|
892
|
-
|
|
1222
|
+
# guard unless the inner executor exempts them. The board tools are in
|
|
1223
|
+
# the same family for the same reason: two `team_task_claim` calls
|
|
1224
|
+
# without a taskId are distinct claims of different items, and the
|
|
1225
|
+
# second must observe the board's new state rather than a cached
|
|
1226
|
+
# first result.
|
|
1227
|
+
if call.name in self._config.tool_names or call.name in self._config.team_tool_names:
|
|
893
1228
|
return True
|
|
894
1229
|
inner_exempt = getattr(self._inner, "dedup_exempt", None)
|
|
895
1230
|
return bool(inner_exempt and inner_exempt(call))
|
|
@@ -921,5 +1256,9 @@ class OrchestrationExecutor:
|
|
|
921
1256
|
self._config.interrupt_tool,
|
|
922
1257
|
}:
|
|
923
1258
|
return False
|
|
1259
|
+
if call.name in self._config.team_tool_names:
|
|
1260
|
+
# A claim must see the board a sibling claim left behind, so a
|
|
1261
|
+
# round that emits several of them runs them in order.
|
|
1262
|
+
return False
|
|
924
1263
|
inner_safe = getattr(self._inner, "concurrency_safe", None)
|
|
925
1264
|
return bool(inner_safe and inner_safe(call))
|