@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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/python-runtime/manifest.json +1 -1
  3. package/python-runtime/python/Lib/site-packages/cffi-2.1.1.dist-info/RECORD +1 -1
  4. package/python-runtime/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
  5. package/python-runtime/python/Lib/site-packages/idna-3.20.dist-info/RECORD +1 -1
  6. package/python-runtime/python/Lib/site-packages/jsonschema-4.26.0.dist-info/RECORD +1 -1
  7. package/python-runtime/python/Lib/site-packages/mcp-1.30.0.dist-info/RECORD +1 -1
  8. package/python-runtime/python/Lib/site-packages/python_dotenv-1.2.4.dist-info/RECORD +1 -1
  9. package/python-runtime/python/Lib/site-packages/pywin32-312.dist-info/RECORD +2 -2
  10. 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
  11. 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
  12. 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
  13. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.11.0.dist-info/RECORD +11 -0
  14. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/__init__.py +12 -0
  15. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/orchestration.py +374 -35
  16. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/peer.py +192 -0
  17. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/pool.py +96 -2
  18. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/subagent.py +27 -8
  19. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/team_board.py +229 -0
  20. package/python-runtime/python/Lib/site-packages/steerable_agent_runtime/tree.py +155 -0
  21. 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
  22. 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
  23. package/python-runtime/python/Lib/site-packages/steerable_sidecar/sidecar.py +49 -1
  24. package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/METADATA +25 -1
  25. package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/RECORD +12 -12
  26. package/python-runtime/python/Lib/site-packages/uvicorn-0.54.0.dist-info/RECORD +1 -1
  27. package/python-runtime/python/Scripts/cffi-gen-src.exe +0 -0
  28. package/python-runtime/python/Scripts/dotenv.exe +0 -0
  29. package/python-runtime/python/Scripts/httpx.exe +0 -0
  30. package/python-runtime/python/Scripts/idna.exe +0 -0
  31. package/python-runtime/python/Scripts/jsonschema.exe +0 -0
  32. package/python-runtime/python/Scripts/mcp.exe +0 -0
  33. package/python-runtime/python/Scripts/pip.exe +0 -0
  34. package/python-runtime/python/Scripts/pip3.12.exe +0 -0
  35. package/python-runtime/python/Scripts/pip3.exe +0 -0
  36. package/python-runtime/python/Scripts/pywin32_postinstall.exe +0 -0
  37. package/python-runtime/python/Scripts/pywin32_testall.exe +0 -0
  38. package/python-runtime/python/Scripts/steerable-sidecar-acp.exe +0 -0
  39. package/python-runtime/python/Scripts/steerable-sidecar-headless.exe +0 -0
  40. package/python-runtime/python/Scripts/steerable-sidecar.exe +0 -0
  41. package/python-runtime/python/Scripts/uvicorn.exe +0 -0
  42. package/runtime.json +67 -55
  43. package/python-runtime/python/Lib/site-packages/steerable_agent_protocol-0.10.2.dist-info/RECORD +0 -11
  44. /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
  45. /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
  46. /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
  47. /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
  48. /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
  49. /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
  50. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/INSTALLER +0 -0
  51. /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.2.dist-info → steerable_sidecar-0.11.0.dist-info}/WHEEL +0 -0
  52. /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
- def fn(name: str, description: str, properties: dict, required: list[str]) -> dict:
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
- fn(
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 pool's sub-agents with their status (running / "
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. A child from an
645
- # earlier turn (or before a restart) has no handle here — try the
646
- # durable index before telling the model the child is unknown.
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, not just the ones running now.
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({"children": entries}),
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
- if call.name in self._config.tool_names:
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))