@steerable/runtime-win32-x64 0.10.3 → 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.3.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.3.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.3.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.3.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.3.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.3.dist-info → steerable_sidecar-0.11.0.dist-info}/METADATA +25 -1
- package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.3.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.3.dist-info/RECORD +0 -11
- /package/python-runtime/python/Lib/site-packages/{steerable_agent_harness-0.10.3.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.3.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.3.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.3.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.3.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.3.dist-info → steerable_agent_runtime-0.11.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/INSTALLER +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/WHEEL +0 -0
- /package/python-runtime/python/Lib/site-packages/{steerable_sidecar-0.10.3.dist-info → steerable_sidecar-0.11.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Tree-wide messaging, as a tool layer any agent in the job can carry.
|
|
2
|
+
|
|
3
|
+
The spawn tree (``tree.py``) decides who is reachable; this module is what
|
|
4
|
+
puts that decision in front of a model. It lives apart from
|
|
5
|
+
``orchestration.py`` because messaging and delegating are different
|
|
6
|
+
permissions that happen to share a tree:
|
|
7
|
+
|
|
8
|
+
- delegating is bounded by depth — a ``max_depth=1`` child must not spawn;
|
|
9
|
+
- messaging is not — the whole point of a shared tree is that a worker can
|
|
10
|
+
hand a finding to a sibling or answer the agent that spawned it.
|
|
11
|
+
|
|
12
|
+
Two surfaces carry it. ``OrchestrationExecutor`` answers these names where it
|
|
13
|
+
advertises the full family; ``PeerToolsExecutor`` wraps a child that gets no
|
|
14
|
+
family at all (a depth-limited worker, or a ``delegate_subagent`` child),
|
|
15
|
+
giving it exactly ``agent_send`` and ``agent_list``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from steerable_agent_protocol.generated import ToolCall, ToolResult
|
|
24
|
+
|
|
25
|
+
from .loop import LoopContext, ToolExecutor
|
|
26
|
+
from .pool import OrchestrationConfig
|
|
27
|
+
from .tree import AgentTree
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"PeerToolsExecutor",
|
|
31
|
+
"peer_schemas",
|
|
32
|
+
"send_tool_descriptor",
|
|
33
|
+
"tool_schema",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def tool_schema(
|
|
38
|
+
name: str, description: str, properties: dict, required: list[str]
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
"""One OpenAI function schema.
|
|
41
|
+
|
|
42
|
+
Shared by the three tool families so their JSON has one shape — a host
|
|
43
|
+
that strict-validates ``additionalProperties: false`` should not have to
|
|
44
|
+
special-case which family a schema came from.
|
|
45
|
+
"""
|
|
46
|
+
return {
|
|
47
|
+
"type": "function",
|
|
48
|
+
"function": {
|
|
49
|
+
"name": name,
|
|
50
|
+
"description": description,
|
|
51
|
+
"parameters": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": properties,
|
|
54
|
+
"required": required,
|
|
55
|
+
"additionalProperties": False,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def send_tool_descriptor(config: OrchestrationConfig) -> dict[str, Any]:
|
|
62
|
+
"""Schema for ``agent_send``, described as tree-wide rather than downward.
|
|
63
|
+
|
|
64
|
+
The description is the documentation a model actually reads: it has to say
|
|
65
|
+
that a sibling and the root are addressable, or a worker will keep routing
|
|
66
|
+
every fact through the parent — the behaviour this slice exists to remove.
|
|
67
|
+
"""
|
|
68
|
+
return tool_schema(
|
|
69
|
+
config.send_tool,
|
|
70
|
+
"Send a message to another agent in this job. A running agent "
|
|
71
|
+
"receives it at its next round boundary (steer); a finished or "
|
|
72
|
+
"interrupted one is resumed with the message as a follow-up turn, "
|
|
73
|
+
"keeping its prior context. Any member of the job is addressable by "
|
|
74
|
+
"lineage id — `0` is the agent that started it, `0.2` a sibling, "
|
|
75
|
+
"`0.2.1` a child — so workers can hand facts to each other instead of "
|
|
76
|
+
"routing everything through the parent. Call agent_list to see the "
|
|
77
|
+
"ids. Closed agents reject sends.",
|
|
78
|
+
{
|
|
79
|
+
"childId": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "Lineage id of the target agent (`0`, `0.2`, `0.2.1`).",
|
|
82
|
+
},
|
|
83
|
+
"message": {"type": "string"},
|
|
84
|
+
},
|
|
85
|
+
["childId", "message"],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def peer_schemas(config: OrchestrationConfig) -> list[dict[str, Any]]:
|
|
90
|
+
"""The two schemas a leaf agent is advertised: send and list."""
|
|
91
|
+
return [
|
|
92
|
+
send_tool_descriptor(config),
|
|
93
|
+
tool_schema(
|
|
94
|
+
config.list_tool,
|
|
95
|
+
"List the agents in this job with their lineage id, depth and "
|
|
96
|
+
"profile — the ids `agent_send` accepts. `self` is your own id.",
|
|
97
|
+
{},
|
|
98
|
+
[],
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PeerToolsExecutor:
|
|
104
|
+
"""ToolExecutor decorator: ``agent_send`` / ``agent_list`` over a tree.
|
|
105
|
+
|
|
106
|
+
For an agent that has no orchestration family — a depth-limited worker, or
|
|
107
|
+
a ``delegate_subagent`` child — and therefore no other way to speak to a
|
|
108
|
+
peer. Nested agents do not use it: their ``OrchestrationExecutor`` already
|
|
109
|
+
answers both names, and wrapping twice would answer once too often.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
inner: ToolExecutor,
|
|
115
|
+
tree: AgentTree,
|
|
116
|
+
config: OrchestrationConfig,
|
|
117
|
+
*,
|
|
118
|
+
lineage: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
self._inner = inner
|
|
121
|
+
self._tree = tree
|
|
122
|
+
self._config = config
|
|
123
|
+
#: Who this agent is on the tree, so the tree can refuse a self-send
|
|
124
|
+
#: and the result can name the sender.
|
|
125
|
+
self._lineage = lineage
|
|
126
|
+
|
|
127
|
+
async def execute(self, call: ToolCall, ctx: LoopContext) -> ToolResult:
|
|
128
|
+
if call.name == self._config.send_tool:
|
|
129
|
+
return await self._send(call)
|
|
130
|
+
if call.name == self._config.list_tool:
|
|
131
|
+
return self._list()
|
|
132
|
+
return await self._inner.execute(call, ctx)
|
|
133
|
+
|
|
134
|
+
async def _send(self, call: ToolCall) -> ToolResult:
|
|
135
|
+
target = str(call.arguments.get("childId") or "").strip()
|
|
136
|
+
message = str(call.arguments.get("message") or "").strip()
|
|
137
|
+
if not message:
|
|
138
|
+
return ToolResult(success=False, error="empty message")
|
|
139
|
+
# The tree owns reachability: its refusal already names every address
|
|
140
|
+
# this agent has (and covers a self-send), which is what lets a model
|
|
141
|
+
# that guessed an id correct itself in the same turn.
|
|
142
|
+
verdict = await self._tree.send(self._lineage, target, message)
|
|
143
|
+
if verdict.startswith("error:"):
|
|
144
|
+
return ToolResult(
|
|
145
|
+
success=False,
|
|
146
|
+
error=verdict[len("error:"):],
|
|
147
|
+
needsFollowup=True,
|
|
148
|
+
)
|
|
149
|
+
member = self._tree.get(target)
|
|
150
|
+
return ToolResult(
|
|
151
|
+
success=True,
|
|
152
|
+
message=json.dumps(
|
|
153
|
+
{
|
|
154
|
+
"childId": target,
|
|
155
|
+
"delivery": verdict,
|
|
156
|
+
**({"depth": member.depth} if member is not None else {}),
|
|
157
|
+
}
|
|
158
|
+
),
|
|
159
|
+
data={"childId": target, "delivery": verdict},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _list(self) -> ToolResult:
|
|
163
|
+
"""Every live member, with this agent's own id.
|
|
164
|
+
|
|
165
|
+
``children`` is present and empty so a leaf and a nested agent answer
|
|
166
|
+
the same tool with the same keys; a host that reads only that key sees
|
|
167
|
+
an honest "none", not a missing field.
|
|
168
|
+
"""
|
|
169
|
+
members = [member.to_wire() for member in self._tree.members()]
|
|
170
|
+
return ToolResult(
|
|
171
|
+
success=True,
|
|
172
|
+
message=json.dumps(
|
|
173
|
+
{"children": [], "members": members, "self": self._lineage}
|
|
174
|
+
),
|
|
175
|
+
data={"count": 0},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def dedup_exempt(self, call: ToolCall) -> bool:
|
|
179
|
+
# A send is stateful: the same message twice is two deliveries, and a
|
|
180
|
+
# repeated list must observe what a send changed.
|
|
181
|
+
if call.name in {self._config.send_tool, self._config.list_tool}:
|
|
182
|
+
return True
|
|
183
|
+
inner_exempt = getattr(self._inner, "dedup_exempt", None)
|
|
184
|
+
return bool(inner_exempt and inner_exempt(call))
|
|
185
|
+
|
|
186
|
+
def concurrency_safe(self, call: ToolCall) -> bool:
|
|
187
|
+
# A send mutates another agent's inbox and, on resume, awaits its
|
|
188
|
+
# first step; serial execution keeps the order the model emitted.
|
|
189
|
+
if call.name in {self._config.send_tool, self._config.list_tool}:
|
|
190
|
+
return False
|
|
191
|
+
inner_safe = getattr(self._inner, "concurrency_safe", None)
|
|
192
|
+
return bool(inner_safe and inner_safe(call))
|
|
@@ -54,6 +54,7 @@ from .history import HistorySeed, entry_to_dict
|
|
|
54
54
|
from .llm import LLMMessage, LLMUsage
|
|
55
55
|
from .loop import CoreLoop
|
|
56
56
|
from .registry import ChildRegistry, SpawnedChild, default_record_id, registry_status
|
|
57
|
+
from .tree import AgentTree, TreeMember
|
|
57
58
|
|
|
58
59
|
logger = logging.getLogger(__name__)
|
|
59
60
|
|
|
@@ -101,6 +102,12 @@ class OrchestrationConfig:
|
|
|
101
102
|
close_tool: str = "agent_close"
|
|
102
103
|
list_tool: str = "agent_list"
|
|
103
104
|
interrupt_tool: str = "agent_interrupt"
|
|
105
|
+
#: R8 shared task board. Advertised only when a board is wired; the names
|
|
106
|
+
#: are configurable so a host can avoid colliding with its own tools.
|
|
107
|
+
task_create_tool: str = "team_task_create"
|
|
108
|
+
task_claim_tool: str = "team_task_claim"
|
|
109
|
+
task_complete_tool: str = "team_task_complete"
|
|
110
|
+
task_list_tool: str = "team_task_list"
|
|
104
111
|
|
|
105
112
|
@property
|
|
106
113
|
def tool_names(self) -> frozenset[str]:
|
|
@@ -115,6 +122,19 @@ class OrchestrationConfig:
|
|
|
115
122
|
}
|
|
116
123
|
)
|
|
117
124
|
|
|
125
|
+
@property
|
|
126
|
+
def team_tool_names(self) -> frozenset[str]:
|
|
127
|
+
"""The four board tools — separate so a nested child narrows its
|
|
128
|
+
orchestration family without losing the shared plan."""
|
|
129
|
+
return frozenset(
|
|
130
|
+
{
|
|
131
|
+
self.task_create_tool,
|
|
132
|
+
self.task_claim_tool,
|
|
133
|
+
self.task_complete_tool,
|
|
134
|
+
self.task_list_tool,
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
118
138
|
|
|
119
139
|
@dataclass(frozen=True, slots=True)
|
|
120
140
|
class ChildOutcome:
|
|
@@ -226,6 +246,7 @@ class AgentPool:
|
|
|
226
246
|
parent_record_id: str | None = None,
|
|
227
247
|
budget: TreeBudget | None = None,
|
|
228
248
|
history_store: Any | None = None,
|
|
249
|
+
tree: AgentTree | None = None,
|
|
229
250
|
) -> None:
|
|
230
251
|
self._config = config
|
|
231
252
|
self._depth = depth
|
|
@@ -264,6 +285,22 @@ class AgentPool:
|
|
|
264
285
|
#: into the child's own record; ``None`` keeps the legacy behaviour
|
|
265
286
|
#: (the seed is just the child's opening messages).
|
|
266
287
|
self._history_store = history_store
|
|
288
|
+
#: Tree-wide addressing, shared by every pool in one spawn tree. Each
|
|
289
|
+
#: pool registers the children it owns, so a peer message reaches an
|
|
290
|
+
#: agent through the owner that can actually steer or resume it.
|
|
291
|
+
#: ``None`` keeps the pre-R8 behaviour: only this pool's own children
|
|
292
|
+
#: are addressable.
|
|
293
|
+
self._tree = tree
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def tree(self) -> AgentTree | None:
|
|
297
|
+
"""The tree this pool's children register into, or ``None``.
|
|
298
|
+
|
|
299
|
+
Exposed so another surface that shares this pool (the delegation seam)
|
|
300
|
+
can give its own children the same peer-addressing tools: membership
|
|
301
|
+
is a property of the tree, not of which executor built the child.
|
|
302
|
+
"""
|
|
303
|
+
return self._tree
|
|
267
304
|
|
|
268
305
|
def _emit(self, kind: str, data: dict[str, Any]) -> None:
|
|
269
306
|
if self._event_sink is not None:
|
|
@@ -378,6 +415,7 @@ class AgentPool:
|
|
|
378
415
|
if system_prompt:
|
|
379
416
|
child_seed.insert(0, LLMMessage.text_of("system", system_prompt))
|
|
380
417
|
handle.task = asyncio.ensure_future(self._run_child(handle, child_seed))
|
|
418
|
+
self._join_tree(child_id, profile=profile)
|
|
381
419
|
loop_record_id = loop.record_id if loop is not None else None
|
|
382
420
|
self._emit(
|
|
383
421
|
"child_spawned",
|
|
@@ -448,6 +486,7 @@ class AgentPool:
|
|
|
448
486
|
)
|
|
449
487
|
self._watch_child(handle, loop)
|
|
450
488
|
handle.task = asyncio.ensure_future(self._run_child(handle, list(seed)))
|
|
489
|
+
self._join_tree(child_id, profile=profile)
|
|
451
490
|
self._emit(
|
|
452
491
|
"child_resumed",
|
|
453
492
|
{
|
|
@@ -459,8 +498,7 @@ class AgentPool:
|
|
|
459
498
|
)
|
|
460
499
|
return handle
|
|
461
500
|
|
|
462
|
-
def _write_seed_entry(
|
|
463
|
-
self,
|
|
501
|
+
def _write_seed_entry( self,
|
|
464
502
|
loop: CoreLoop,
|
|
465
503
|
seed: list[LLMMessage],
|
|
466
504
|
provenance: SeedProvenance,
|
|
@@ -493,6 +531,57 @@ class AgentPool:
|
|
|
493
531
|
except Exception as exc: # noqa: BLE001 — bookkeeping never fails a spawn
|
|
494
532
|
logger.warning("fork seed provenance write failed: %s", exc)
|
|
495
533
|
|
|
534
|
+
def _join_tree(self, child_id: str, *, profile: str | None) -> None:
|
|
535
|
+
"""Make this child addressable by peers while it exists.
|
|
536
|
+
|
|
537
|
+
The pool owns the loop, so it is also the thing that can deliver a
|
|
538
|
+
message to it — registering here is what lets a sibling reach this
|
|
539
|
+
child without knowing which executor built it.
|
|
540
|
+
"""
|
|
541
|
+
if self._tree is None:
|
|
542
|
+
return
|
|
543
|
+
self._tree.register(
|
|
544
|
+
TreeMember(
|
|
545
|
+
lineage=child_id,
|
|
546
|
+
depth=self._depth + 1,
|
|
547
|
+
parent=self._lineage or None,
|
|
548
|
+
profile=profile,
|
|
549
|
+
),
|
|
550
|
+
lambda message, cid=child_id: self._deliver_to_peer(cid, message),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def _leave_tree(self, child_id: str) -> None:
|
|
554
|
+
"""Stop resolving this child's address (closed or wound down)."""
|
|
555
|
+
if self._tree is not None:
|
|
556
|
+
self._tree.unregister(child_id)
|
|
557
|
+
|
|
558
|
+
async def _deliver_to_peer(self, child_id: str, message: str) -> str:
|
|
559
|
+
"""Deliver a peer's message to one child, asynchronously.
|
|
560
|
+
|
|
561
|
+
``AgentPool.send`` is synchronous because a tool call runs inside the
|
|
562
|
+
loop and must answer in one step; a peer message instead arrives from
|
|
563
|
+
another agent's tool call, and resuming that child means awaiting its
|
|
564
|
+
new run's first step. So this routes through the same vocabulary
|
|
565
|
+
``send`` returns (``steered`` / ``resumed`` / ``error:*``) while
|
|
566
|
+
allowing the resume to await.
|
|
567
|
+
"""
|
|
568
|
+
verdict = self.send(child_id, message)
|
|
569
|
+
if verdict != "resumed":
|
|
570
|
+
return verdict
|
|
571
|
+
handle = self._children.get(child_id)
|
|
572
|
+
if handle is None or handle.task is None:
|
|
573
|
+
return "resumed"
|
|
574
|
+
# The resumed child is running; wait for it to settle so the sender
|
|
575
|
+
# learns the outcome rather than a bare "accepted". Shielded: a
|
|
576
|
+
# timeout on the sender's side must not cancel the work.
|
|
577
|
+
try:
|
|
578
|
+
await asyncio.shield(handle.task)
|
|
579
|
+
except asyncio.CancelledError:
|
|
580
|
+
raise
|
|
581
|
+
except Exception: # noqa: BLE001 — the child's own outcome carries it
|
|
582
|
+
pass
|
|
583
|
+
return verdict
|
|
584
|
+
|
|
496
585
|
def _register_child(
|
|
497
586
|
self,
|
|
498
587
|
child_id: str,
|
|
@@ -1039,6 +1128,10 @@ class AgentPool:
|
|
|
1039
1128
|
if handle is None:
|
|
1040
1129
|
return False
|
|
1041
1130
|
handle.closed = True
|
|
1131
|
+
# Closing is the caller's "never again": the address stops resolving
|
|
1132
|
+
# so a peer's message reports an unknown member instead of resuming an
|
|
1133
|
+
# agent the owner already gave up on.
|
|
1134
|
+
self._leave_tree(child_id)
|
|
1042
1135
|
if handle.outcome is not None:
|
|
1043
1136
|
return True
|
|
1044
1137
|
self._cancel_handle(handle)
|
|
@@ -1071,6 +1164,7 @@ class AgentPool:
|
|
|
1071
1164
|
live = [h for h in self._children.values() if h.outcome is None]
|
|
1072
1165
|
for handle in self._children.values():
|
|
1073
1166
|
handle.closed = True
|
|
1167
|
+
self._leave_tree(handle.child_id)
|
|
1074
1168
|
for handle in live:
|
|
1075
1169
|
self._cancel_handle(handle)
|
|
1076
1170
|
for handle in live:
|
|
@@ -69,6 +69,7 @@ from .history import completed_turn_prefix
|
|
|
69
69
|
from .llm import LLMMessage, LLMProvider
|
|
70
70
|
from .loop import CoreLoop, LoopConfig, LoopContext, LoopHooks, ToolExecutor
|
|
71
71
|
from .engines import AcpEngineSpec, AcpPermissionPolicy, AcpToolBridge
|
|
72
|
+
from .peer import PeerToolsExecutor, peer_schemas
|
|
72
73
|
from .pool import (
|
|
73
74
|
AgentPool,
|
|
74
75
|
LoopFactory,
|
|
@@ -441,8 +442,7 @@ class SubagentExecutor:
|
|
|
441
442
|
#: which row is waiting on the user.
|
|
442
443
|
self._event_sink = event_sink
|
|
443
444
|
#: Where an external child's file and terminal requests land. ``None``
|
|
444
|
-
#: means this host grants none (a bridge with no capabilities refuses
|
|
445
|
-
#: everything), which is the safe default for a spawned process.
|
|
445
|
+
#: means this host grants none (a bridge with no capabilities refuses #: everything), which is the safe default for a spawned process.
|
|
446
446
|
self._acp_tool_bridge = acp_tool_bridge
|
|
447
447
|
#: The parent's model-visible transcript, read at delegation time.
|
|
448
448
|
#: ``None`` means this host cannot fork, and the tool schema omits the
|
|
@@ -655,6 +655,30 @@ class SubagentExecutor:
|
|
|
655
655
|
and (tool_filter is None or _schema_name(schema) in tool_filter)
|
|
656
656
|
]
|
|
657
657
|
schemas.append(parent_input_tool_descriptor())
|
|
658
|
+
base: ToolExecutor = _ParentInputExecutor(
|
|
659
|
+
self._child_executor(config),
|
|
660
|
+
lambda payload: self._parent_input_requests.__setitem__(
|
|
661
|
+
child_id, payload
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
# A delegated child is a member of the turn's job: it registers in
|
|
665
|
+
# the same tree the orchestration children do (the pool's
|
|
666
|
+
# ``_join_tree`` does that), so it can hand a finding to a sibling
|
|
667
|
+
# or answer the parent instead of routing everything back through
|
|
668
|
+
# the tool result. Read from the pool rather than a constructor
|
|
669
|
+
# argument because ``attach_pool`` adopts the shared pool after
|
|
670
|
+
# construction — the tree is whatever that pool has.
|
|
671
|
+
tree = self._pool.tree
|
|
672
|
+
if tree is not None:
|
|
673
|
+
# Same names as the orchestration family: a delegated child
|
|
674
|
+
# and an orchestration child are one kind of peer.
|
|
675
|
+
peer_config = OrchestrationConfig(
|
|
676
|
+
max_parallel=self._config.max_parallel
|
|
677
|
+
)
|
|
678
|
+
base = PeerToolsExecutor(
|
|
679
|
+
base, tree, peer_config, lineage=child_id
|
|
680
|
+
)
|
|
681
|
+
schemas = [*schemas, *peer_schemas(peer_config)]
|
|
658
682
|
# A child writes its own durable record when the host gave us a
|
|
659
683
|
# store and a prefix, so the delegation's process can be read
|
|
660
684
|
# back afterwards (`<parent record>:child:<lineage id>`). The
|
|
@@ -668,12 +692,7 @@ class SubagentExecutor:
|
|
|
668
692
|
return (
|
|
669
693
|
CoreLoop(
|
|
670
694
|
provider,
|
|
671
|
-
|
|
672
|
-
self._child_executor(config),
|
|
673
|
-
lambda payload: self._parent_input_requests.__setitem__(
|
|
674
|
-
child_id, payload
|
|
675
|
-
),
|
|
676
|
-
),
|
|
695
|
+
base,
|
|
677
696
|
LoopConfig(
|
|
678
697
|
max_rounds=config.max_rounds,
|
|
679
698
|
max_tool_errors=config.max_tool_errors,
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""The shared task board: one plan the whole spawn tree works from.
|
|
2
|
+
|
|
3
|
+
Without it, splitting a job means the parent hands out self-contained tasks
|
|
4
|
+
and then **remembers** who is doing what. That memory is the parent's context,
|
|
5
|
+
which is exactly the thing that gets long when a job is big enough to need four
|
|
6
|
+
agents. A board moves the plan out of any one context: the parent writes down
|
|
7
|
+
the work, workers claim items, and anyone can see what is still unclaimed.
|
|
8
|
+
|
|
9
|
+
Two decisions worth naming:
|
|
10
|
+
|
|
11
|
+
- **Claiming is atomic.** Two workers that both see "auth refactor: todo" must
|
|
12
|
+
not both start it; ``claim`` either binds the caller or fails. That is the
|
|
13
|
+
whole point of a board over a list in a prompt.
|
|
14
|
+
- **The board does not block.** ``claim`` returning "nothing free" is
|
|
15
|
+
information, not a reason to wait. A worker that waits on the board would
|
|
16
|
+
idle a slot the parent is budgeting for, so the caller decides what to do
|
|
17
|
+
next — and ``team_task_list`` shows what exists.
|
|
18
|
+
|
|
19
|
+
Ownership is the caller's lineage id (``0.2``), so the board also answers "who
|
|
20
|
+
is doing this" without a second registry.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from dataclasses import dataclass, field, replace
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
|
|
29
|
+
__all__ = ["TaskItem", "TeamTaskBoard"]
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
#: Statuses in the order work moves through them. ``todo`` is unclaimed; the
|
|
34
|
+
#: rest exist so a reader can tell "nobody took it" from "someone has it" from
|
|
35
|
+
#: "it is done" without reading every entry.
|
|
36
|
+
TASK_STATUSES = ("todo", "claimed", "done")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now_iso() -> str:
|
|
40
|
+
return datetime.now(timezone.utc).isoformat()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class TaskItem:
|
|
45
|
+
"""One unit of work on the board.
|
|
46
|
+
|
|
47
|
+
``owner`` is the claimant's lineage id (``None`` while ``todo``), and
|
|
48
|
+
``depends_on`` names other task ids — the shape DSH's team board uses, so a
|
|
49
|
+
worker can tell "I can start" from "I am waiting on someone".
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
title: str
|
|
54
|
+
status: str
|
|
55
|
+
owner: str | None = None
|
|
56
|
+
detail: str | None = None
|
|
57
|
+
depends_on: tuple[str, ...] = ()
|
|
58
|
+
created_by: str | None = None
|
|
59
|
+
created_at: str = ""
|
|
60
|
+
updated_at: str = ""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def blocked(self) -> bool:
|
|
64
|
+
"""True while a dependency is still unfinished.
|
|
65
|
+
|
|
66
|
+
Computed by the board (which can see the others), not here: an item
|
|
67
|
+
alone cannot know whether its dependencies are done.
|
|
68
|
+
"""
|
|
69
|
+
return bool(self.depends_on)
|
|
70
|
+
|
|
71
|
+
def to_wire(self) -> dict[str, object]:
|
|
72
|
+
"""Camel-case payload for a tool result."""
|
|
73
|
+
return {
|
|
74
|
+
"id": self.id,
|
|
75
|
+
"title": self.title,
|
|
76
|
+
"status": self.status,
|
|
77
|
+
**({"owner": self.owner} if self.owner else {}),
|
|
78
|
+
**({"detail": self.detail} if self.detail else {}),
|
|
79
|
+
**({"dependsOn": list(self.depends_on)} if self.depends_on else {}),
|
|
80
|
+
**({"createdBy": self.created_by} if self.created_by else {}),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class TeamTaskBoard:
|
|
86
|
+
"""One board per spawn tree, shared by every member.
|
|
87
|
+
|
|
88
|
+
The board is plain in-process state: it belongs to a turn tree, and a
|
|
89
|
+
tree does not outlive the turn that started it. Nothing here is durable on
|
|
90
|
+
purpose — a finished turn's plan has no reader, and persisting it would
|
|
91
|
+
create a second place where "what was this job" lives.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
_items: dict[str, TaskItem] = field(default_factory=dict)
|
|
95
|
+
_seq: int = 0
|
|
96
|
+
|
|
97
|
+
def create(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
title: str,
|
|
101
|
+
detail: str | None = None,
|
|
102
|
+
depends_on: tuple[str, ...] = (),
|
|
103
|
+
created_by: str | None = None,
|
|
104
|
+
) -> TaskItem:
|
|
105
|
+
"""Add one unclaimed item and return it.
|
|
106
|
+
|
|
107
|
+
Dependencies are validated here rather than at claim time: an item
|
|
108
|
+
that names a task nobody created would never become claimable, and the
|
|
109
|
+
author is the one who can fix the typo.
|
|
110
|
+
"""
|
|
111
|
+
missing = [dep for dep in depends_on if dep not in self._items]
|
|
112
|
+
if missing:
|
|
113
|
+
raise KeyError(
|
|
114
|
+
f"unknown dependenc{'y' if len(missing) == 1 else 'ies'}: "
|
|
115
|
+
f"{', '.join(missing)}"
|
|
116
|
+
)
|
|
117
|
+
self._seq += 1
|
|
118
|
+
now = _now_iso()
|
|
119
|
+
item = TaskItem(
|
|
120
|
+
id=f"t{self._seq}",
|
|
121
|
+
title=title,
|
|
122
|
+
status="todo",
|
|
123
|
+
detail=detail,
|
|
124
|
+
depends_on=tuple(depends_on),
|
|
125
|
+
created_by=created_by,
|
|
126
|
+
created_at=now,
|
|
127
|
+
updated_at=now,
|
|
128
|
+
)
|
|
129
|
+
self._items[item.id] = item
|
|
130
|
+
return item
|
|
131
|
+
|
|
132
|
+
def get(self, task_id: str) -> TaskItem | None:
|
|
133
|
+
"""One item, or ``None`` when the id is unknown."""
|
|
134
|
+
return self._items.get(task_id)
|
|
135
|
+
|
|
136
|
+
def items(self) -> list[TaskItem]:
|
|
137
|
+
"""Every item, in creation order."""
|
|
138
|
+
return list(self._items.values())
|
|
139
|
+
|
|
140
|
+
def ready(self) -> list[TaskItem]:
|
|
141
|
+
"""Unclaimed items whose dependencies are all done.
|
|
142
|
+
|
|
143
|
+
This is what a worker without a specific id should claim: the answer
|
|
144
|
+
"nothing ready, three blocked" is actionable in a way that a flat
|
|
145
|
+
unclaimed list is not.
|
|
146
|
+
"""
|
|
147
|
+
done = {item.id for item in self._items.values() if item.status == "done"}
|
|
148
|
+
return [
|
|
149
|
+
item
|
|
150
|
+
for item in self._items.values()
|
|
151
|
+
if item.status == "todo" and all(dep in done for dep in item.depends_on)
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
def claim(self, task_id: str, *, owner: str) -> TaskItem:
|
|
155
|
+
"""Bind one ready item to ``owner``.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
KeyError: the id is unknown.
|
|
159
|
+
ValueError: the item is not claimable (already claimed, done, or
|
|
160
|
+
still blocked) — the message says which, because "cannot
|
|
161
|
+
claim" alone tells the caller nothing about whether to wait,
|
|
162
|
+
pick another item, or fix a dependency.
|
|
163
|
+
|
|
164
|
+
Claiming is the one operation that must not race: two workers reading
|
|
165
|
+
the same ``todo`` and both proceeding would duplicate the work, so the
|
|
166
|
+
check and the bind happen in one step here and nowhere else.
|
|
167
|
+
"""
|
|
168
|
+
item = self._items.get(task_id)
|
|
169
|
+
if item is None:
|
|
170
|
+
raise KeyError(f"unknown task {task_id!r}")
|
|
171
|
+
if item.status == "done":
|
|
172
|
+
raise ValueError(f"task {task_id} is already done")
|
|
173
|
+
if item.status == "claimed":
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"task {task_id} is already claimed by {item.owner}"
|
|
176
|
+
)
|
|
177
|
+
done = {other.id for other in self._items.values() if other.status == "done"}
|
|
178
|
+
blocked = [dep for dep in item.depends_on if dep not in done]
|
|
179
|
+
if blocked:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"task {task_id} is blocked by {', '.join(blocked)}"
|
|
182
|
+
)
|
|
183
|
+
claimed = replace(
|
|
184
|
+
item, status="claimed", owner=owner, updated_at=_now_iso()
|
|
185
|
+
)
|
|
186
|
+
self._items[task_id] = claimed
|
|
187
|
+
return claimed
|
|
188
|
+
|
|
189
|
+
def complete(
|
|
190
|
+
self, task_id: str, *, owner: str | None = None, note: str | None = None
|
|
191
|
+
) -> TaskItem:
|
|
192
|
+
"""Mark one claimed item done.
|
|
193
|
+
|
|
194
|
+
``owner`` is checked when given: a worker finishing another worker's
|
|
195
|
+
item is far more often a mistake (wrong id) than intent, and the
|
|
196
|
+
message names the real owner so the caller can correct itself.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
KeyError: the id is unknown.
|
|
200
|
+
ValueError: the item is not claimed, or claimed by someone else.
|
|
201
|
+
"""
|
|
202
|
+
item = self._items.get(task_id)
|
|
203
|
+
if item is None:
|
|
204
|
+
raise KeyError(f"unknown task {task_id!r}")
|
|
205
|
+
if item.status == "done":
|
|
206
|
+
return item
|
|
207
|
+
if item.status != "claimed":
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"task {task_id} is {item.status}, not claimed; claim it first"
|
|
210
|
+
)
|
|
211
|
+
if owner is not None and item.owner is not None and item.owner != owner:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"task {task_id} is claimed by {item.owner}, not {owner}"
|
|
214
|
+
)
|
|
215
|
+
detail = item.detail if note is None else (
|
|
216
|
+
f"{item.detail}\n{note}" if item.detail else note
|
|
217
|
+
)
|
|
218
|
+
done = replace(
|
|
219
|
+
item, status="done", detail=detail, updated_at=_now_iso()
|
|
220
|
+
)
|
|
221
|
+
self._items[task_id] = done
|
|
222
|
+
return done
|
|
223
|
+
|
|
224
|
+
def summary(self) -> dict[str, int]:
|
|
225
|
+
"""Counts by status, for a one-line board overview."""
|
|
226
|
+
counts = {status: 0 for status in TASK_STATUSES}
|
|
227
|
+
for item in self._items.values():
|
|
228
|
+
counts[item.status] = counts.get(item.status, 0) + 1
|
|
229
|
+
return counts
|