@ian-pascoe/pi-minimal-subagents 0.13.0 → 0.14.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/README.md +60 -25
- package/package.json +2 -2
- package/src/minimal-subagents-config.ts +11 -6
- package/src/minimal-subagents-coordinator.ts +189 -9
- package/src/minimal-subagents-delivery-ledger.ts +14 -13
- package/src/minimal-subagents-extension.ts +59 -2
- package/src/minimal-subagents-registry-wire.ts +1 -0
- package/src/minimal-subagents-registry.ts +3 -0
- package/src/minimal-subagents-render-contract.ts +3 -0
- package/src/minimal-subagents-rendering.ts +3 -0
- package/src/minimal-subagents-sessions.ts +3 -1
- package/src/minimal-subagents-tool-schemas.ts +8 -0
- package/src/minimal-subagents-tools.ts +6 -3
- package/src/minimal-subagents-types.ts +6 -0
package/README.md
CHANGED
|
@@ -39,9 +39,13 @@ From this package checkout, load the source directly with
|
|
|
39
39
|
tool-resolution `warnings`. Spawns run sequentially, so a `subagent_wait` or
|
|
40
40
|
`agent_message` in the same tool batch can target the new child.
|
|
41
41
|
2. `subagent_wait` claims the turn's result, or leave it unclaimed and the final
|
|
42
|
-
response arrives automatically as a steer message.
|
|
42
|
+
response arrives automatically as a steer message. Without `turn_id`, a wait
|
|
43
|
+
skips turns whose result you already claimed or received automatically.
|
|
43
44
|
3. `agent_message` continues an idle child: the result reports
|
|
44
|
-
`disposition: "started-turn"` with the new `turn_id
|
|
45
|
+
`disposition: "started-turn"` with the new `turn_id`. A plain
|
|
46
|
+
`subagent_wait({ agent_id })` targets that new turn, not the first result;
|
|
47
|
+
pass `turn_id` to re-read a turn whose result is still retained, claimed or
|
|
48
|
+
not.
|
|
45
49
|
4. `subagent_status` inspects children; `subagent_cancel` stops work but keeps
|
|
46
50
|
the session; `subagent_delete` removes it. Deleted IDs cannot be reused.
|
|
47
51
|
|
|
@@ -93,10 +97,10 @@ source branch, and a new branch without an override follows settings.
|
|
|
93
97
|
|
|
94
98
|
## Model roles
|
|
95
99
|
|
|
96
|
-
`minimalSubagents.modelRoles` gives the parent agent advisory
|
|
100
|
+
`minimalSubagents.modelRoles` gives the parent agent named, advisory choices of
|
|
97
101
|
eligible models. The extension defines no roles itself, performs no task
|
|
98
|
-
classification, and does not route launches
|
|
99
|
-
|
|
102
|
+
classification, and does not route launches: the parent names the role it wants
|
|
103
|
+
with the optional `role` argument of `subagent`.
|
|
100
104
|
|
|
101
105
|
```json
|
|
102
106
|
{
|
|
@@ -113,8 +117,9 @@ ordinary `model` and `thinking_level` arguments separately.
|
|
|
113
117
|
```
|
|
114
118
|
|
|
115
119
|
A recognized final suffix (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`,
|
|
116
|
-
or `max`) is
|
|
117
|
-
|
|
120
|
+
or `max`) is the role's `thinking_level`, not part of the canonical model.
|
|
121
|
+
Unsuffixed roles leave thinking selection independent: the child uses the
|
|
122
|
+
caller's thinking level unless `thinking_level` is passed.
|
|
118
123
|
Role names and hints are trimmed, single-line text. Names may be up to 64
|
|
119
124
|
characters and hints up to 500 characters. Models use canonical
|
|
120
125
|
`provider/model` IDs and must be available under the effective `enabledModels`
|
|
@@ -125,6 +130,27 @@ preference. A thinking level pinned in `enabledModels` neither supplies nor
|
|
|
125
130
|
constrains a role preference, and normal spawn-time model-capability clamping
|
|
126
131
|
still applies.
|
|
127
132
|
|
|
133
|
+
### Launching with a role
|
|
134
|
+
|
|
135
|
+
`subagent({ task, role: "design" })` launches the child with the `design` role's
|
|
136
|
+
model and, when the role has a suffix, its thinking level. An explicit `model` or
|
|
137
|
+
`thinking_level` overrides the role's value independently, so
|
|
138
|
+
`{ role: "design", thinking_level: "low" }` keeps the role's model with a
|
|
139
|
+
different thinking level. The role is recorded even when explicit `model` and
|
|
140
|
+
`thinking_level` override all of its values. An unknown `role` fails before any
|
|
141
|
+
child is created and lists the configured role names. The Launch Contract
|
|
142
|
+
records the resolved `model` and `thinking_level` together with the `role` used
|
|
143
|
+
(`role` is absent when none was named), and `subagent_status` and the expanded
|
|
144
|
+
`subagent` result show it. Recording the name does not tie the child to the role: later settings
|
|
145
|
+
changes never alter an existing Launch Contract.
|
|
146
|
+
|
|
147
|
+
Roles are read when the session starts, so editing `modelRoles` mid-session
|
|
148
|
+
changes nothing until a reload. The `role` parameter is a plain string rather
|
|
149
|
+
than an enum of the configured names, so the tool definition stays
|
|
150
|
+
byte-identical when `modelRoles` changes. Only the role list in the system
|
|
151
|
+
prompt (the `subagent` prompt guidelines) changes, and only at reload. The
|
|
152
|
+
`model` enum is separate: it already follows the eligible models.
|
|
153
|
+
|
|
128
154
|
Global and project roles merge by name in settings order. Expanded role
|
|
129
155
|
objects merge by field; a project string replaces the whole global entry. A
|
|
130
156
|
project can remove one inherited role with `null`, or clear all inherited
|
|
@@ -310,10 +336,13 @@ intermediate Wait Event containing a Coordination Message before the child turn
|
|
|
310
336
|
settles. That event claims only its message, so later unconsumed messages and the
|
|
311
337
|
terminal result retain automatic fallback. If the turn has already settled, a
|
|
312
338
|
wait returns its terminal result with queued messages in `messages`; waiting
|
|
313
|
-
again for the same settled turn returns the same result. Pass
|
|
314
|
-
optional `turn_id` to address
|
|
315
|
-
|
|
316
|
-
|
|
339
|
+
again for the same settled turn returns the same result while it is retained. Pass
|
|
340
|
+
optional `turn_id` to address a retained turn exactly, including one whose
|
|
341
|
+
result you already claimed (a claimed result stays retained until Delivery
|
|
342
|
+
Evidence settles it). Without it, waits skip claimed turns and turns whose
|
|
343
|
+
result was already delivered to you automatically, and select the oldest
|
|
344
|
+
remaining observable turn, falling back to the active, then latest, turn. A
|
|
345
|
+
caller may have only one outstanding wait for the same source turn; a
|
|
317
346
|
concurrent duplicate is rejected instead of competing for one Wait Event.
|
|
318
347
|
When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
|
|
319
348
|
with the requested turn identity and the same detailed Child Agent status used
|
|
@@ -321,20 +350,26 @@ by targeted `subagent_status`. It removes only the waiter, leaving the child
|
|
|
321
350
|
running and all pending delivery unclaimed. Abort signals remain errors.
|
|
322
351
|
|
|
323
352
|
The persisted Delivery Ledger records Coordination Messages, terminal results,
|
|
324
|
-
globally increasing sequence, and wait ownership before delivery. Existing
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
pending terminal result absorbs them.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
353
|
+
globally increasing sequence, and wait ownership before delivery. Existing items
|
|
354
|
+
retain their sequence; gaps from skipped malformed records are valid. Claims can
|
|
355
|
+
name only active, latest, or retained turns. Wait-returned messages retain
|
|
356
|
+
individual delivery evidence; terminal wait ownership remains durable across
|
|
357
|
+
reloads, forks, and newer turns. Automatic fallback retains its ordered queue
|
|
358
|
+
reservation while batching queued messages from one source turn into one Pi
|
|
359
|
+
steer. Root-bound messages remain batchable while the root turn is active; a
|
|
360
|
+
pending terminal result absorbs them. If Pi discards a root-bound steer before
|
|
361
|
+
consuming it, as Esc does, the next root turn boundary reporting an empty Pi
|
|
362
|
+
queue releases the result: a default wait selects it again at once, and
|
|
363
|
+
automatic fallback re-sends it, ahead of newer results, into the root run if it
|
|
364
|
+
is still going, or, after an abort such as Esc, when the root's next run starts.
|
|
365
|
+
Recovery never starts a root turn by itself. Child sessions drain all available
|
|
366
|
+
steers before the next model call. Destination-session Delivery Evidence still
|
|
367
|
+
settles each batched ledger item independently, preventing duplicate delivery
|
|
368
|
+
and unbounded checkpoint growth. The pure Delivery Ledger state machine retains
|
|
369
|
+
at most 20 pending wait-only terminal results per source agent; Coordination
|
|
370
|
+
Messages are not removed by that terminal-retention limit. Delivered messages
|
|
371
|
+
include stable delivery, source-agent, and source-turn identities in persisted
|
|
372
|
+
details.
|
|
338
373
|
|
|
339
374
|
Deleting a child first verifies its session header and persistent identity,
|
|
340
375
|
then uses the optional `trash` command when available and falls back to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-minimal-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Persistent nested subagents with bounded delegation for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
]
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "vitest run
|
|
56
|
+
"test": "vitest run",
|
|
57
57
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -45,6 +45,16 @@ const ModelRolesSettingSchema = Type.Union([ModelRoleEntriesSchema, Type.Null()]
|
|
|
45
45
|
|
|
46
46
|
type ModelRoleThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
47
47
|
|
|
48
|
+
/** Whether `name` is trimmed single-line text of at most 64 characters, as every role name must be. */
|
|
49
|
+
export function isModelRoleName(name: string): boolean {
|
|
50
|
+
return (
|
|
51
|
+
name.length > 0 &&
|
|
52
|
+
name === name.trim() &&
|
|
53
|
+
!/[\r\n]/.test(name) &&
|
|
54
|
+
name.length <= MODEL_ROLE_NAME_MAX_LENGTH
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
/** Describes one user-authored advisory model role shown to subagent callers. */
|
|
49
59
|
export interface MinimalSubagentsModelRole {
|
|
50
60
|
name: string;
|
|
@@ -294,12 +304,7 @@ function parseModelRoles(
|
|
|
294
304
|
const roles: MinimalSubagentsModelRole[] = [];
|
|
295
305
|
for (const [name, entry] of entries) {
|
|
296
306
|
const path = `${entry.scope} minimalSubagents.modelRoles.${name}`;
|
|
297
|
-
if (
|
|
298
|
-
name.length === 0 ||
|
|
299
|
-
name !== name.trim() ||
|
|
300
|
-
/[\r\n]/.test(name) ||
|
|
301
|
-
name.length > MODEL_ROLE_NAME_MAX_LENGTH
|
|
302
|
-
) {
|
|
307
|
+
if (!isModelRoleName(name)) {
|
|
303
308
|
warnings.push(`${path}: role name must be trimmed single-line text up to 64 characters`);
|
|
304
309
|
continue;
|
|
305
310
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
claimDeliveryLedgerTurn,
|
|
24
24
|
createDeliveryLedger,
|
|
25
25
|
deliveryLedgerSnapshot,
|
|
26
|
+
deliveryTurnKey,
|
|
26
27
|
findCoordinationDelivery,
|
|
27
28
|
findTerminalDelivery,
|
|
28
29
|
isDeliveryLedgerTurnClaimed,
|
|
@@ -42,6 +43,7 @@ import { withTroubleshootingHint } from "./troubleshooting-skill.js";
|
|
|
42
43
|
import { addCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
|
|
43
44
|
import { unavailableAgent } from "./minimal-subagents-sessions.js";
|
|
44
45
|
import { createRegistryEvent } from "./minimal-subagents-registry.js";
|
|
46
|
+
import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
|
|
45
47
|
import type {
|
|
46
48
|
ActiveTurnProgress,
|
|
47
49
|
AgentDetail,
|
|
@@ -109,6 +111,16 @@ interface PendingParentMessage {
|
|
|
109
111
|
cancelGrace?: () => void;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
/** One terminal result queued to its parent, until Delivery Evidence settles it or Pi discards it. */
|
|
115
|
+
interface TerminalHandOff {
|
|
116
|
+
/** Hand-off order, compared with the root's last ended message at a turn boundary. */
|
|
117
|
+
order: number;
|
|
118
|
+
/** Whether the result entered Pi's clearable steer queue instead of starting a root turn. */
|
|
119
|
+
queued: boolean;
|
|
120
|
+
/** Coordination Messages batched into the same steer. */
|
|
121
|
+
coordinationDeliveryIds: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
function agentDeliveryKey(agentId: string, turnId: string): string {
|
|
113
125
|
return `${agentId}\u0000${turnId}`;
|
|
114
126
|
}
|
|
@@ -162,9 +174,18 @@ export class MinimalSubagentsCoordinator {
|
|
|
162
174
|
private readonly waiters = new Map<string, Set<TurnWaiter>>();
|
|
163
175
|
private readonly pendingParentMessages = new Map<string, PendingParentMessage[]>();
|
|
164
176
|
private readonly recipientQueues = new Map<string, Promise<unknown>>();
|
|
165
|
-
|
|
177
|
+
/** In-flight automatic result hand-offs, each with the lifecycle epoch that owns it. */
|
|
178
|
+
private readonly automaticDeliveryKeys = new Map<string, number>();
|
|
166
179
|
private readonly automaticCoordinationDeliveryIds = new Set<string>();
|
|
167
180
|
private readonly waitHandedDeliveryIds = new Set<string>();
|
|
181
|
+
/** Terminal results already queued to their parent, until Delivery Evidence settles them. */
|
|
182
|
+
private readonly handedTerminalKeys = new Map<string, TerminalHandOff>();
|
|
183
|
+
/** Monotonic order of hand-offs, compared against the root's last ended message. */
|
|
184
|
+
private handOffOrder = 0;
|
|
185
|
+
/** The latest hand-off order a root `message_end` has seen; later hand-offs may postdate Pi's queue snapshot. */
|
|
186
|
+
private rootMessageEndOrder = 0;
|
|
187
|
+
/** Root results Pi discarded, awaiting the root's next run; each keeps its Delivery Ledger sequence. */
|
|
188
|
+
private readonly discardedRootResultKeys = new Set<string>();
|
|
168
189
|
private readonly backgroundOperations = new Set<Promise<void>>();
|
|
169
190
|
private acceptingOperations = true;
|
|
170
191
|
private lifecycleEpoch = 0;
|
|
@@ -222,8 +243,10 @@ export class MinimalSubagentsCoordinator {
|
|
|
222
243
|
|
|
223
244
|
const sessionContext = parameters.session_context ?? "omit";
|
|
224
245
|
const projectContext = parameters.project_context ?? "inherit";
|
|
225
|
-
const
|
|
226
|
-
const
|
|
246
|
+
const role = parameters.role === undefined ? undefined : this.requireModelRole(parameters.role);
|
|
247
|
+
const model = parameters.model ?? role?.model ?? caller.model;
|
|
248
|
+
const requestedThinking =
|
|
249
|
+
parameters.thinking_level ?? role?.thinkingLevel ?? caller.thinkingLevel;
|
|
227
250
|
const thinkingLevel = this.dependencies.sessions.resolveThinkingLevel(model, requestedThinking);
|
|
228
251
|
const {
|
|
229
252
|
ordinaryTools,
|
|
@@ -268,6 +291,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
268
291
|
missing_dependencies: [],
|
|
269
292
|
recent_messages: [],
|
|
270
293
|
};
|
|
294
|
+
// Added after `delegation`, matching the key order of replayed Launch Contracts.
|
|
295
|
+
if (role) agent.launch_contract.role = role.name;
|
|
271
296
|
this.pendingAgentIds.add(agentId);
|
|
272
297
|
let identity: ReturnType<AgentSessionFactory["createIdentity"]>;
|
|
273
298
|
try {
|
|
@@ -352,6 +377,19 @@ export class MinimalSubagentsCoordinator {
|
|
|
352
377
|
return result;
|
|
353
378
|
}
|
|
354
379
|
|
|
380
|
+
private requireModelRole(name: string): MinimalSubagentsModelRole {
|
|
381
|
+
const roles = this.dependencies.modelRoles ?? [];
|
|
382
|
+
const role = roles.find((candidate) => candidate.name === name);
|
|
383
|
+
if (role) return role;
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Minimal subagents spawn validation: unknown role ${JSON.stringify(name)}; ${
|
|
386
|
+
roles.length === 0
|
|
387
|
+
? "no model roles are configured"
|
|
388
|
+
: `configured roles: ${roles.map((candidate) => candidate.name).join(", ")}`
|
|
389
|
+
}`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
355
393
|
/** Capture immutable launch defaults for a nested caller from its active child runtime. */
|
|
356
394
|
snapshotChildCaller(agentId: string, spawnEntryId: string): CallerSnapshot {
|
|
357
395
|
const agent = this.requireAgent(agentId);
|
|
@@ -456,7 +494,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
456
494
|
if (agent.active_turn_id !== turnId) {
|
|
457
495
|
return Promise.reject(
|
|
458
496
|
new Error(
|
|
459
|
-
`Minimal subagents wait: turn ${turnId} is unknown or no longer retained for ${agentId}; omit turn_id to wait for its oldest observable turn`,
|
|
497
|
+
`Minimal subagents wait: turn ${turnId} is unknown or no longer retained for ${agentId}; omit turn_id to wait for its oldest observable turn you have neither claimed nor received`,
|
|
460
498
|
),
|
|
461
499
|
);
|
|
462
500
|
}
|
|
@@ -720,6 +758,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
720
758
|
this.waiters.clear();
|
|
721
759
|
this.waitHandedDeliveryIds.clear();
|
|
722
760
|
this.automaticDeliveryKeys.clear();
|
|
761
|
+
this.handedTerminalKeys.clear();
|
|
762
|
+
this.discardedRootResultKeys.clear();
|
|
723
763
|
this.automaticCoordinationDeliveryIds.clear();
|
|
724
764
|
this.deliveryLedger = createDeliveryLedger({
|
|
725
765
|
deliveries: snapshot.deliveries,
|
|
@@ -864,6 +904,102 @@ export class MinimalSubagentsCoordinator {
|
|
|
864
904
|
await Promise.allSettled(scheduled);
|
|
865
905
|
}
|
|
866
906
|
|
|
907
|
+
/** Note that a root message ended; results handed before it reached Pi's queue before the next turn boundary. */
|
|
908
|
+
recordRootMessageEnd(): void {
|
|
909
|
+
this.rootMessageEndOrder = this.handOffOrder;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Release results Pi discarded from the root's steer queue, as Esc does.
|
|
914
|
+
*
|
|
915
|
+
* Call only at a root turn boundary whose pending-message snapshot is empty. Pi persists a
|
|
916
|
+
* consumed steer before that boundary, so a queued result handed before the turn's last message
|
|
917
|
+
* either has Delivery Evidence and settles, or was discarded. A discarded result is selectable
|
|
918
|
+
* by a default wait again at once, with its batched Coordination Messages; automatic fallback
|
|
919
|
+
* re-sends it when the root's next run starts (`deliverDiscardedRootResults`).
|
|
920
|
+
*/
|
|
921
|
+
requeueDiscardedRootResults(): void {
|
|
922
|
+
if (!this.acceptingOperations) return;
|
|
923
|
+
const candidates = this.deliveryLedger.terminalDeliveries
|
|
924
|
+
.filter((delivery) => {
|
|
925
|
+
if (delivery.destination_agent_id !== "root") return false;
|
|
926
|
+
const handOff = this.handedTerminalKeys.get(
|
|
927
|
+
deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
|
|
928
|
+
);
|
|
929
|
+
return handOff?.queued === true && handOff.order <= this.rootMessageEndOrder;
|
|
930
|
+
})
|
|
931
|
+
.sort(
|
|
932
|
+
(left, right) =>
|
|
933
|
+
(left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
for (const delivery of candidates) {
|
|
937
|
+
const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
|
|
938
|
+
const handOff = this.handedTerminalKeys.get(handedKey);
|
|
939
|
+
if (!handOff) continue;
|
|
940
|
+
const batched = handOff.coordinationDeliveryIds.flatMap((deliveryId) => {
|
|
941
|
+
const item = findCoordinationDelivery(this.deliveryLedger, deliveryId);
|
|
942
|
+
return item ? [item] : [];
|
|
943
|
+
});
|
|
944
|
+
if (this.hasDeliveryEvidence(delivery)) {
|
|
945
|
+
for (const item of batched) {
|
|
946
|
+
if (this.hasCoordinationDeliveryEvidence(item)) this.settleCoordinationDelivery(item);
|
|
947
|
+
}
|
|
948
|
+
this.settleDelivery(delivery);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
this.handedTerminalKeys.delete(handedKey);
|
|
953
|
+
this.discardedRootResultKeys.add(handedKey);
|
|
954
|
+
for (const item of batched) {
|
|
955
|
+
this.waitHandedDeliveryIds.delete(item.delivery_id);
|
|
956
|
+
this.queuePendingParentMessage("root", item.message, item);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Whether a root result waits in Pi's clearable steer queue, so turn boundaries must be watched. */
|
|
962
|
+
hasQueuedRootHandOffs(): boolean {
|
|
963
|
+
for (const handOff of this.handedTerminalKeys.values()) if (handOff.queued) return true;
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Queue results Pi discarded into a root run that just started, in Delivery Ledger sequence.
|
|
969
|
+
*
|
|
970
|
+
* Recovery never starts a root turn by itself, so Esc stays respected: the results wait, still
|
|
971
|
+
* selectable by a default wait, until the user or another delivery starts the root's next run.
|
|
972
|
+
*/
|
|
973
|
+
deliverDiscardedRootResults(): void {
|
|
974
|
+
if (
|
|
975
|
+
this.discardedRootResultKeys.size === 0 ||
|
|
976
|
+
!this.acceptingOperations ||
|
|
977
|
+
this.dependencies.root.isIdle()
|
|
978
|
+
)
|
|
979
|
+
return;
|
|
980
|
+
const discarded = this.deliveryLedger.terminalDeliveries
|
|
981
|
+
.filter((delivery) =>
|
|
982
|
+
this.discardedRootResultKeys.has(
|
|
983
|
+
deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
|
|
984
|
+
),
|
|
985
|
+
)
|
|
986
|
+
.sort(
|
|
987
|
+
(left, right) =>
|
|
988
|
+
(left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
|
|
989
|
+
);
|
|
990
|
+
this.discardedRootResultKeys.clear();
|
|
991
|
+
for (const delivery of discarded) {
|
|
992
|
+
const result = delivery.result ?? this.agents.get(delivery.source_agent_id)?.latest_result;
|
|
993
|
+
if (
|
|
994
|
+
delivery.path !== "message" ||
|
|
995
|
+
result?.status !== "completed" ||
|
|
996
|
+
result.turn_id !== delivery.source_turn_id
|
|
997
|
+
)
|
|
998
|
+
continue;
|
|
999
|
+
this.trackBackgroundOperation(this.deliverAutomaticResult(result, delivery, true));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
867
1003
|
/** Clone complete child leaves for root fork ownership without ever sharing source session paths. */
|
|
868
1004
|
async prepareFork(sourceRootSessionFile: string): Promise<ForkSnapshot> {
|
|
869
1005
|
const activeRootChildren = this.childrenOf("root");
|
|
@@ -1157,17 +1293,27 @@ export class MinimalSubagentsCoordinator {
|
|
|
1157
1293
|
if (result.status !== "completed") this.removeSettledEmptyTurnClaim(agent.agent_id, turnId);
|
|
1158
1294
|
}
|
|
1159
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* Hand one terminal result to its parent through automatic fallback.
|
|
1298
|
+
*
|
|
1299
|
+
* By default it waits its turn in the recipient queue, then the wait-claim grace period. A
|
|
1300
|
+
* `resume` hand-off re-sends a root result Pi discarded: its grace period has passed and its
|
|
1301
|
+
* sequence precedes everything still queued, so it is handed at once, synchronously up to Pi.
|
|
1302
|
+
*/
|
|
1160
1303
|
private async deliverAutomaticResult(
|
|
1161
1304
|
result: TurnResult,
|
|
1162
1305
|
delivery: PersistedDelivery,
|
|
1306
|
+
resume = false,
|
|
1163
1307
|
): Promise<void> {
|
|
1164
1308
|
const deliveryKey = agentDeliveryKey(delivery.source_agent_id, delivery.source_turn_id);
|
|
1309
|
+
const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
|
|
1165
1310
|
if (this.automaticDeliveryKeys.has(deliveryKey)) return;
|
|
1166
|
-
this.
|
|
1167
|
-
|
|
1311
|
+
const epoch = this.lifecycleEpoch;
|
|
1312
|
+
this.automaticDeliveryKeys.set(deliveryKey, epoch);
|
|
1313
|
+
const graceMs = resume ? 0 : this.deliveryGraceMs();
|
|
1168
1314
|
let batchedCoordinationDeliveries: PersistedCoordinationDelivery[] = [];
|
|
1169
1315
|
try {
|
|
1170
|
-
|
|
1316
|
+
const handOff = async () => {
|
|
1171
1317
|
if (delivery.destination_agent_id !== "root") {
|
|
1172
1318
|
await this.ensureRuntime(
|
|
1173
1319
|
this.requireUsableAgent(delivery.destination_agent_id, "message"),
|
|
@@ -1175,6 +1321,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
1175
1321
|
if (!this.isTerminalDeliveryCurrent(delivery)) return;
|
|
1176
1322
|
}
|
|
1177
1323
|
if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
1324
|
+
// A branch change during the grace period abandons this hand-off; restore replays it.
|
|
1325
|
+
if (epoch !== this.lifecycleEpoch) return;
|
|
1178
1326
|
if (!this.acceptingOperations || this.shouldStopAutomaticTerminalDelivery(delivery)) return;
|
|
1179
1327
|
if (this.hasDeliveryEvidence(delivery)) {
|
|
1180
1328
|
this.settleDelivery(delivery);
|
|
@@ -1200,6 +1348,13 @@ export class MinimalSubagentsCoordinator {
|
|
|
1200
1348
|
for (const batchedDelivery of batchedCoordinationDeliveries) {
|
|
1201
1349
|
this.waitHandedDeliveryIds.add(batchedDelivery.delivery_id);
|
|
1202
1350
|
}
|
|
1351
|
+
// Installed before delivery so a settlement inside it removes the marker. Delivery up to
|
|
1352
|
+
// Pi's `sendMessage` is synchronous, so the idle check predicts a steer versus a new turn.
|
|
1353
|
+
this.handedTerminalKeys.set(handedKey, {
|
|
1354
|
+
order: ++this.handOffOrder,
|
|
1355
|
+
queued: delivery.destination_agent_id === "root" && !this.dependencies.root.isIdle(),
|
|
1356
|
+
coordinationDeliveryIds: batchedCoordinationDeliveries.map((item) => item.delivery_id),
|
|
1357
|
+
});
|
|
1203
1358
|
await this.deliverToRecipient(
|
|
1204
1359
|
delivery.destination_agent_id,
|
|
1205
1360
|
combineCoordinatorMessages([
|
|
@@ -1210,8 +1365,14 @@ export class MinimalSubagentsCoordinator {
|
|
|
1210
1365
|
this.isTerminalDeliveryCurrent(delivery) &&
|
|
1211
1366
|
batchedCoordinationDeliveries.every((item) => this.isCoordinationDeliveryCurrent(item)),
|
|
1212
1367
|
);
|
|
1213
|
-
}
|
|
1368
|
+
};
|
|
1369
|
+
await (resume
|
|
1370
|
+
? handOff()
|
|
1371
|
+
: this.enqueueRecipientDelivery(delivery.destination_agent_id, handOff));
|
|
1214
1372
|
} catch (error) {
|
|
1373
|
+
// A hand-off abandoned by a branch change must not touch the restored branch's state.
|
|
1374
|
+
if (epoch !== this.lifecycleEpoch) return;
|
|
1375
|
+
this.handedTerminalKeys.delete(handedKey);
|
|
1215
1376
|
for (const batchedDelivery of batchedCoordinationDeliveries) {
|
|
1216
1377
|
this.waitHandedDeliveryIds.delete(batchedDelivery.delivery_id);
|
|
1217
1378
|
}
|
|
@@ -1231,7 +1392,10 @@ export class MinimalSubagentsCoordinator {
|
|
|
1231
1392
|
}),
|
|
1232
1393
|
);
|
|
1233
1394
|
} finally {
|
|
1234
|
-
|
|
1395
|
+
// Restore's replay may own the key again; release it only from the call that holds it.
|
|
1396
|
+
if (this.automaticDeliveryKeys.get(deliveryKey) === epoch) {
|
|
1397
|
+
this.automaticDeliveryKeys.delete(deliveryKey);
|
|
1398
|
+
}
|
|
1235
1399
|
}
|
|
1236
1400
|
}
|
|
1237
1401
|
|
|
@@ -1317,6 +1481,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1317
1481
|
sourceAgentId,
|
|
1318
1482
|
destinationAgentId,
|
|
1319
1483
|
waitHandedDeliveryIds: this.waitHandedDeliveryIds,
|
|
1484
|
+
handedTurnKeys: this.handedTerminalKeys,
|
|
1320
1485
|
activeTurnId: agent?.active_turn_id,
|
|
1321
1486
|
latestResultTurnId: agent?.latest_result?.turn_id,
|
|
1322
1487
|
});
|
|
@@ -1532,6 +1697,19 @@ export class MinimalSubagentsCoordinator {
|
|
|
1532
1697
|
}),
|
|
1533
1698
|
);
|
|
1534
1699
|
}
|
|
1700
|
+
this.dropStaleHandedTerminalKeys();
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** Forget handed markers whose terminal delivery left the ledger by settlement, pruning, or deletion. */
|
|
1704
|
+
private dropStaleHandedTerminalKeys(): void {
|
|
1705
|
+
const pending = new Set(
|
|
1706
|
+
this.deliveryLedger.terminalDeliveries.map((delivery) =>
|
|
1707
|
+
deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
|
|
1708
|
+
),
|
|
1709
|
+
);
|
|
1710
|
+
for (const key of this.handedTerminalKeys.keys()) {
|
|
1711
|
+
if (!pending.has(key)) this.handedTerminalKeys.delete(key);
|
|
1712
|
+
}
|
|
1535
1713
|
}
|
|
1536
1714
|
|
|
1537
1715
|
private isTerminalDeliveryCurrent(delivery: PersistedDelivery): boolean {
|
|
@@ -1607,6 +1785,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1607
1785
|
delivery.source_agent_id,
|
|
1608
1786
|
delivery.source_turn_id,
|
|
1609
1787
|
).ledger;
|
|
1788
|
+
this.dropStaleHandedTerminalKeys();
|
|
1610
1789
|
this.dependencies.registry.append(
|
|
1611
1790
|
createRegistryEvent(this.dependencies.registry.rootSessionId, "delivery-settled", {
|
|
1612
1791
|
source_agent_id: delivery.source_agent_id,
|
|
@@ -1672,6 +1851,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1672
1851
|
private pruneDeliveryStateForDeletedAgent(agentId: string): void {
|
|
1673
1852
|
const previousCoordinationDeliveries = this.deliveryLedger.coordinationDeliveries;
|
|
1674
1853
|
this.deliveryLedger = pruneDeliveryLedgerAgents(this.deliveryLedger, [agentId]).ledger;
|
|
1854
|
+
this.dropStaleHandedTerminalKeys();
|
|
1675
1855
|
for (const delivery of previousCoordinationDeliveries) {
|
|
1676
1856
|
if (!this.isCoordinationDeliveryCurrent(delivery)) {
|
|
1677
1857
|
this.waitHandedDeliveryIds.delete(delivery.delivery_id);
|
|
@@ -45,16 +45,19 @@ export interface AddedCoordinationDelivery extends DeliveryLedgerTransition {
|
|
|
45
45
|
delivery: PersistedCoordinationDelivery;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** Describes caller-visible inputs used to select the oldest observable source turn. */
|
|
48
|
+
/** Describes caller-visible inputs used to select the oldest observable source turn the destination has neither claimed nor received. */
|
|
49
49
|
export interface SelectObservableDeliveryTurnOptions {
|
|
50
50
|
sourceAgentId: string;
|
|
51
51
|
destinationAgentId: string;
|
|
52
52
|
waitHandedDeliveryIds: ReadonlySet<string>;
|
|
53
|
+
/** `deliveryTurnKey`s of terminal results already queued to the destination automatically. */
|
|
54
|
+
handedTurnKeys?: { has(key: string): boolean };
|
|
53
55
|
activeTurnId?: string;
|
|
54
56
|
latestResultTurnId?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
/** Compound source-agent and source-turn key shared by claims and process-local handed markers. */
|
|
60
|
+
export function deliveryTurnKey(sourceAgentId: string, sourceTurnId: string): string {
|
|
58
61
|
return `${sourceAgentId}\u0000${sourceTurnId}`;
|
|
59
62
|
}
|
|
60
63
|
|
|
@@ -420,19 +423,20 @@ export function releaseEmptyDeliveryLedgerTurn(
|
|
|
420
423
|
: releaseDeliveryLedgerTurn(ledger, sourceAgentId, sourceTurnId);
|
|
421
424
|
}
|
|
422
425
|
|
|
423
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* Select the oldest sequenced observable source turn whose terminal result the caller has neither
|
|
428
|
+
* claimed nor been handed automatically, falling back to the active, then latest, turn.
|
|
429
|
+
*/
|
|
424
430
|
export function selectObservableDeliveryTurn(
|
|
425
431
|
ledger: DeliveryLedger,
|
|
426
432
|
options: SelectObservableDeliveryTurnOptions,
|
|
427
433
|
): string | undefined {
|
|
428
|
-
const candidates = new Map<string,
|
|
434
|
+
const candidates = new Map<string, number>();
|
|
429
435
|
const addCandidate = (turnId: string, sequence: number) => {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
sequence,
|
|
433
|
-
};
|
|
436
|
+
const turnKey = deliveryTurnKey(options.sourceAgentId, turnId);
|
|
437
|
+
if (ledger.waitClaimedTurns.includes(turnKey) || options.handedTurnKeys?.has(turnKey)) return;
|
|
434
438
|
const existing = candidates.get(turnId);
|
|
435
|
-
if (
|
|
439
|
+
if (existing === undefined || sequence < existing) candidates.set(turnId, sequence);
|
|
436
440
|
};
|
|
437
441
|
for (const delivery of ledger.coordinationDeliveries) {
|
|
438
442
|
if (
|
|
@@ -451,10 +455,7 @@ export function selectObservableDeliveryTurn(
|
|
|
451
455
|
addCandidate(delivery.source_turn_id, delivery.sequence ?? Number.MAX_SAFE_INTEGER);
|
|
452
456
|
}
|
|
453
457
|
}
|
|
454
|
-
const retained = [...candidates.entries()].sort((left, right) =>
|
|
455
|
-
if (left[1].claimed !== right[1].claimed) return left[1].claimed ? -1 : 1;
|
|
456
|
-
return left[1].sequence - right[1].sequence;
|
|
457
|
-
})[0]?.[0];
|
|
458
|
+
const retained = [...candidates.entries()].sort((left, right) => left[1] - right[1])[0]?.[0];
|
|
458
459
|
return retained ?? options.activeTurnId ?? options.latestResultTurnId;
|
|
459
460
|
}
|
|
460
461
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type SessionShutdownEvent,
|
|
18
18
|
type SessionStartEvent,
|
|
19
19
|
type SessionTreeEvent,
|
|
20
|
+
type TurnEndEvent,
|
|
20
21
|
} from "@earendil-works/pi-coding-agent";
|
|
21
22
|
import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
|
|
22
23
|
import {
|
|
@@ -182,9 +183,12 @@ function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): Caller
|
|
|
182
183
|
function createRootConversationEndpoint(
|
|
183
184
|
pi: ExtensionAPI,
|
|
184
185
|
context: ExtensionContext,
|
|
186
|
+
onSteer: () => void,
|
|
185
187
|
): RootConversationEndpoint {
|
|
186
188
|
return {
|
|
187
189
|
async queueCoordinatorMessage(message) {
|
|
190
|
+
// A running root takes the message as a clearable steer; an idle root starts a turn with it.
|
|
191
|
+
if (!context.isIdle()) onSteer();
|
|
188
192
|
pi.sendMessage(
|
|
189
193
|
{
|
|
190
194
|
customType: message.customType,
|
|
@@ -517,6 +521,7 @@ export class MinimalSubagentsLifecycleController {
|
|
|
517
521
|
private uiController: MinimalSubagentsUiController | undefined;
|
|
518
522
|
private statusPanelController: MinimalSubagentsStatusPanelController | undefined;
|
|
519
523
|
private viewerShortcut: ReturnType<typeof installViewerShortcut> | undefined;
|
|
524
|
+
private unwatchRootTurnEnds: (() => void) | undefined;
|
|
520
525
|
private accessSession: ActiveSubagentAccessSession | undefined;
|
|
521
526
|
private preparedFork:
|
|
522
527
|
| { sourceSessionFile: string; selectedBranchSnapshot: RegistrySnapshot }
|
|
@@ -528,7 +533,10 @@ export class MinimalSubagentsLifecycleController {
|
|
|
528
533
|
private readonly effects: MinimalSubagentsLifecycleEffects,
|
|
529
534
|
) {}
|
|
530
535
|
|
|
531
|
-
/**
|
|
536
|
+
/**
|
|
537
|
+
* Register stable tools, renderers, and the six static Pi lifecycle event handlers. `turn_end` is
|
|
538
|
+
* registered only while a result waits in the root's steer queue (`watchRootTurnEnds`).
|
|
539
|
+
*/
|
|
532
540
|
register(): void {
|
|
533
541
|
const rootTools = createCoordinatorToolDefinitions({
|
|
534
542
|
coordinator: this.coordinatorOperations,
|
|
@@ -552,6 +560,7 @@ export class MinimalSubagentsLifecycleController {
|
|
|
552
560
|
this.pi.on("session_before_fork", (event, context) => this.prepareSessionFork(event, context));
|
|
553
561
|
this.pi.on("session_tree", (event, context) => this.restoreSessionTree(event, context));
|
|
554
562
|
this.pi.on("message_end", (event, context) => this.reconcileMessageDelivery(event, context));
|
|
563
|
+
this.pi.on("agent_start", () => this.deliverDiscardedRootResults());
|
|
555
564
|
this.pi.on("session_shutdown", (event, context) => this.shutdownSession(event, context));
|
|
556
565
|
}
|
|
557
566
|
|
|
@@ -645,9 +654,10 @@ export class MinimalSubagentsLifecycleController {
|
|
|
645
654
|
});
|
|
646
655
|
activeCoordinator = new MinimalSubagentsCoordinator({
|
|
647
656
|
sessions: sessionFactory,
|
|
648
|
-
root: createRootConversationEndpoint(this.pi, context),
|
|
657
|
+
root: createRootConversationEndpoint(this.pi, context, () => this.watchRootTurnEnds()),
|
|
649
658
|
maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
|
|
650
659
|
toolsets: minimalSubagentsConfig.toolsets,
|
|
660
|
+
modelRoles: minimalSubagentsConfig.modelRoles,
|
|
651
661
|
registry: {
|
|
652
662
|
rootSessionId,
|
|
653
663
|
append: (registryEvent) => this.pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
|
|
@@ -925,16 +935,63 @@ export class MinimalSubagentsLifecycleController {
|
|
|
925
935
|
_context: ExtensionContext,
|
|
926
936
|
): Promise<void> {
|
|
927
937
|
if (!this.coordinator) return;
|
|
938
|
+
this.coordinator.recordRootMessageEnd();
|
|
928
939
|
if (event.message.role === "toolResult" || event.message.role === "custom") {
|
|
929
940
|
await this.coordinator.reconcileDeliveries();
|
|
930
941
|
this.uiController?.refresh();
|
|
931
942
|
}
|
|
932
943
|
}
|
|
933
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Watch root turn boundaries while a result waits in the root's steer queue. Pi builds a boundary
|
|
947
|
+
* preview for every `turn_end` while any handler listens, so the handler is removed again once no
|
|
948
|
+
* queued result remains.
|
|
949
|
+
*/
|
|
950
|
+
private watchRootTurnEnds(): void {
|
|
951
|
+
this.unwatchRootTurnEnds ??= this.pi.on("turn_end", (event, context) =>
|
|
952
|
+
this.requeueDiscardedRootResults(event, context),
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
private stopWatchingRootTurnEnds(): void {
|
|
957
|
+
this.unwatchRootTurnEnds?.();
|
|
958
|
+
this.unwatchRootTurnEnds = undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Pi gives extensions no queue-cleared event, so Esc's discarded steers are found here: the turn
|
|
963
|
+
* boundary's pending-message preview is empty. Returns nothing, so the transcript, system prompt,
|
|
964
|
+
* and tools are unchanged.
|
|
965
|
+
*
|
|
966
|
+
* A queue clear without an abort (dequeue, RPC `clear_queue`) leaves the run going, so released
|
|
967
|
+
* results are steered back at once and Pi's poll after this boundary takes them in the same run.
|
|
968
|
+
* Esc aborts the run, so its released results wait for the root's next run.
|
|
969
|
+
*/
|
|
970
|
+
private requeueDiscardedRootResults(event: TurnEndEvent, context: ExtensionContext): void {
|
|
971
|
+
if (!this.coordinator) {
|
|
972
|
+
this.stopWatchingRootTurnEnds();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (event.context.pendingMessages.length === 0) {
|
|
976
|
+
this.coordinator.requeueDiscardedRootResults();
|
|
977
|
+
if (context.signal && !context.signal.aborted) {
|
|
978
|
+
this.coordinator.deliverDiscardedRootResults();
|
|
979
|
+
}
|
|
980
|
+
this.uiController?.refresh();
|
|
981
|
+
}
|
|
982
|
+
if (!this.coordinator.hasQueuedRootHandOffs()) this.stopWatchingRootTurnEnds();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Re-send discarded results into a root run that just started; recovery never starts one. */
|
|
986
|
+
private deliverDiscardedRootResults(): void {
|
|
987
|
+
this.coordinator?.deliverDiscardedRootResults();
|
|
988
|
+
}
|
|
989
|
+
|
|
934
990
|
private async shutdownSession(
|
|
935
991
|
event: SessionShutdownEvent,
|
|
936
992
|
context: ExtensionContext,
|
|
937
993
|
): Promise<void> {
|
|
994
|
+
this.stopWatchingRootTurnEnds();
|
|
938
995
|
this.viewerShortcut?.dispose();
|
|
939
996
|
this.viewerShortcut = undefined;
|
|
940
997
|
this.statusPanelController?.dispose();
|
|
@@ -56,6 +56,7 @@ const ToolSelectionWireSchema = Type.Union([
|
|
|
56
56
|
|
|
57
57
|
/** Parses persisted immutable Launch Contracts without coercion. */
|
|
58
58
|
export const RegistryLaunchContractWireSchema = Type.Object({
|
|
59
|
+
role: Type.Optional(NonEmptyStringSchema),
|
|
59
60
|
session_context: Type.Union([
|
|
60
61
|
Type.Literal("inherit"),
|
|
61
62
|
Type.Literal("compact"),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Value } from "typebox/value";
|
|
3
3
|
import { COORDINATOR_TOOL_NAMES } from "./minimal-subagents-capabilities.js";
|
|
4
|
+
import { isModelRoleName } from "./minimal-subagents-config.js";
|
|
4
5
|
import { normalizeMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
|
|
5
6
|
import {
|
|
6
7
|
createDeliveryLedger,
|
|
@@ -304,6 +305,7 @@ function parseRegistryLaunchContract(
|
|
|
304
305
|
): LaunchContract | undefined {
|
|
305
306
|
if (
|
|
306
307
|
!isCanonicalModelId(value.model) ||
|
|
308
|
+
(value.role !== undefined && !isModelRoleName(value.role)) ||
|
|
307
309
|
!isUniqueNonEmptyStringArray(value.ordinary_tools) ||
|
|
308
310
|
!excludesCoordinatorToolNames(value.ordinary_tools) ||
|
|
309
311
|
(Array.isArray(value.tools) &&
|
|
@@ -320,6 +322,7 @@ function parseRegistryLaunchContract(
|
|
|
320
322
|
ordinary_tools: [...value.ordinary_tools],
|
|
321
323
|
};
|
|
322
324
|
if (value.delegation !== undefined) launchContract.delegation = value.delegation;
|
|
325
|
+
if (value.role !== undefined) launchContract.role = value.role;
|
|
323
326
|
return launchContract;
|
|
324
327
|
}
|
|
325
328
|
|
|
@@ -23,6 +23,7 @@ const RenderUsageSchema = Type.Object({
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
const RenderLaunchContractSchema = Type.Object({
|
|
26
|
+
role: Type.Optional(Type.String()),
|
|
26
27
|
session_context: Type.Optional(Type.String()),
|
|
27
28
|
project_context: Type.Optional(Type.String()),
|
|
28
29
|
model: Type.Optional(Type.String()),
|
|
@@ -87,6 +88,7 @@ const SpawnCallArgumentsSchema = Type.Object({
|
|
|
87
88
|
agent_id: Type.Optional(Type.String()),
|
|
88
89
|
session_context: Type.Optional(Type.String()),
|
|
89
90
|
project_context: Type.Optional(Type.String()),
|
|
91
|
+
role: Type.Optional(Type.String()),
|
|
90
92
|
model: Type.Optional(Type.String()),
|
|
91
93
|
thinking_level: Type.Optional(Type.String()),
|
|
92
94
|
delegation: Type.Optional(Type.String()),
|
|
@@ -219,6 +221,7 @@ const CoordinatorAgentDetailOutputSchema = Type.Object({
|
|
|
219
221
|
...CoordinatorAgentSummaryOutputSchema.properties,
|
|
220
222
|
session_file: Type.Optional(Type.String()),
|
|
221
223
|
launch_contract: Type.Object({
|
|
224
|
+
role: Type.Optional(Type.String()),
|
|
222
225
|
session_context: Type.String(),
|
|
223
226
|
project_context: Type.String(),
|
|
224
227
|
model: Type.String(),
|
|
@@ -445,6 +445,7 @@ function spawnCallLaunchSummary(args: SpawnCallArguments): string[] {
|
|
|
445
445
|
? undefined
|
|
446
446
|
: `tools ${Array.isArray(args.tools) ? args.tools.join(", ") || "none" : args.tools}`;
|
|
447
447
|
return [
|
|
448
|
+
args.role ? `role ${args.role}` : undefined,
|
|
448
449
|
profile,
|
|
449
450
|
tools,
|
|
450
451
|
args.session_context && args.session_context !== "omit"
|
|
@@ -507,6 +508,7 @@ function renderSpawnResult(
|
|
|
507
508
|
`delegation ${launchContract?.delegation ?? args.delegation ?? "none"}`,
|
|
508
509
|
`session context ${launchContract?.session_context ?? args.session_context ?? "omit"}`,
|
|
509
510
|
`project context ${launchContract?.project_context ?? args.project_context ?? "inherit"}`,
|
|
511
|
+
launchContract?.role ? `role ${launchContract.role}` : undefined,
|
|
510
512
|
resolvedModel ? `model ${resolvedModel}` : undefined,
|
|
511
513
|
resolvedThinking ? `thinking ${resolvedThinking}` : undefined,
|
|
512
514
|
].filter((value): value is string => value !== undefined);
|
|
@@ -770,6 +772,7 @@ function renderDetailedStatusAgent(
|
|
|
770
772
|
const launchValues = [
|
|
771
773
|
`session context ${launchContract.session_context ?? "inherit"}`,
|
|
772
774
|
`project context ${launchContract.project_context ?? "inherit"}`,
|
|
775
|
+
...(launchContract.role ? [`role ${launchContract.role}`] : []),
|
|
773
776
|
`model ${launchContract.model ?? agent.model ?? "unknown"}`,
|
|
774
777
|
`thinking ${launchContract.thinking_level ?? agent.thinking_level ?? "unknown"}`,
|
|
775
778
|
`delegation ${launchContract.delegation ?? "none"}`,
|
|
@@ -503,7 +503,9 @@ export function findDeliveryEvidence(
|
|
|
503
503
|
))
|
|
504
504
|
);
|
|
505
505
|
}
|
|
506
|
-
|
|
506
|
+
// A message or timeout wait result never carried the turn's terminal result.
|
|
507
|
+
if (waitToolResult && (details.event === "message" || details.event === "timeout"))
|
|
508
|
+
return false;
|
|
507
509
|
return details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId;
|
|
508
510
|
});
|
|
509
511
|
}
|
|
@@ -26,6 +26,13 @@ const ToolSelectionSchema = Type.Union(
|
|
|
26
26
|
'Configurable presets: "read" adds readToolset; "modify" adds readToolset plus modifyToolset. Defaults: read, grep, find, ls; modify additionally grants bash, edit, write. Configured baseToolset applies to all selections, including "none" and exact arrays. Omit to inherit the caller\'s active ordinary tools plus base. All grants stay within the caller\'s capability ceiling; coordinator tools are injected separately.',
|
|
27
27
|
},
|
|
28
28
|
);
|
|
29
|
+
// Deliberately a plain string rather than an enum of the configured roles: settings can change, and
|
|
30
|
+
// the tool definition must stay byte-identical for the session so provider prompt caches survive.
|
|
31
|
+
const ModelRoleSchema = Type.String({
|
|
32
|
+
minLength: 1,
|
|
33
|
+
description:
|
|
34
|
+
"Name of a configured model role (minimalSubagents.modelRoles) listed in the Subagent tool guidelines. Resolves to the role's model and, when the role has one, its thinking level. An explicit model or thinking_level overrides the role's value. An unknown role is rejected with the configured role names.",
|
|
35
|
+
});
|
|
29
36
|
const FRIENDLY_AGENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$";
|
|
30
37
|
const CANONICAL_AGENT_ID_PATTERN =
|
|
31
38
|
"^(?:root\\.)?[A-Za-z0-9][A-Za-z0-9_-]{0,63}(?:\\.[A-Za-z0-9][A-Za-z0-9_-]{0,63})*$";
|
|
@@ -55,6 +62,7 @@ export function createCoordinatorToolSchemas(modelIds: readonly string[]) {
|
|
|
55
62
|
),
|
|
56
63
|
session_context: Type.Optional(SessionContextSchema),
|
|
57
64
|
project_context: Type.Optional(ProjectContextSchema),
|
|
65
|
+
role: Type.Optional(ModelRoleSchema),
|
|
58
66
|
model: Type.Optional(explicitModelSchema),
|
|
59
67
|
thinking_level: Type.Optional(ThinkingLevelSchema),
|
|
60
68
|
tools: Type.Optional(ToolSelectionSchema),
|
|
@@ -82,8 +82,11 @@ function buildModelRolePromptGuidelines(
|
|
|
82
82
|
return ` - ${role.name} → model=${role.model}${thinkingGuidance}${role.hint ? ` — ${role.hint}` : ""}`;
|
|
83
83
|
});
|
|
84
84
|
return [
|
|
85
|
-
[
|
|
86
|
-
|
|
85
|
+
[
|
|
86
|
+
"Configured model roles are guidance, not constraints. Pass role to launch with a role's model and thinking_level; an explicit model or thinking_level overrides the role's value:",
|
|
87
|
+
...roleLines,
|
|
88
|
+
].join("\n"),
|
|
89
|
+
"Choose a role or model based on the task. A listed thinking_level is a preference, not a constraint. Callers choose thinking_level independently for roles without one.",
|
|
87
90
|
];
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -310,7 +313,7 @@ export function createCoordinatorToolDefinitions(
|
|
|
310
313
|
name: "subagent_wait",
|
|
311
314
|
label: "Subagent Wait",
|
|
312
315
|
description:
|
|
313
|
-
"Wait for one direct child's oldest observable turn, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns the same result. Timeout returns event=timeout with detailed child status and never cancels the child.",
|
|
316
|
+
"Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns the same result. Timeout returns event=timeout with detailed child status and never cancels the child.",
|
|
314
317
|
promptSnippet: "Wait for one direct child's exact turn",
|
|
315
318
|
parameters: options.schemas.subagent_wait,
|
|
316
319
|
annotations: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
|
|
4
5
|
|
|
5
6
|
/** Controls how much committed caller conversation enters a new child session. */
|
|
6
7
|
export type SessionContextMode = "inherit" | "compact" | "omit";
|
|
@@ -35,6 +36,8 @@ export interface SpawnParameters {
|
|
|
35
36
|
agent_id?: string;
|
|
36
37
|
session_context?: SessionContextMode;
|
|
37
38
|
project_context?: ProjectContextMode;
|
|
39
|
+
/** Configured model role whose model and thinking level apply unless overridden below. */
|
|
40
|
+
role?: string;
|
|
38
41
|
model?: string;
|
|
39
42
|
thinking_level?: ThinkingLevel;
|
|
40
43
|
tools?: ToolSelection;
|
|
@@ -199,6 +202,8 @@ export interface DeleteResult {
|
|
|
199
202
|
|
|
200
203
|
/** Persists immutable context, model, thinking, and ordinary-tool launch choices. */
|
|
201
204
|
export interface LaunchContract extends RuntimeProfile {
|
|
205
|
+
/** Configured model role the caller named at launch; absent when none was used. */
|
|
206
|
+
role?: string;
|
|
202
207
|
session_context: SessionContextMode;
|
|
203
208
|
project_context: ProjectContextMode;
|
|
204
209
|
tools: ToolSelection | undefined;
|
|
@@ -424,6 +429,7 @@ export interface CoordinatorDependencies {
|
|
|
424
429
|
root: RootConversationEndpoint;
|
|
425
430
|
maxSubagentDepth?: number;
|
|
426
431
|
toolsets?: MinimalSubagentsToolsets;
|
|
432
|
+
modelRoles?: readonly MinimalSubagentsModelRole[];
|
|
427
433
|
now?: () => Date;
|
|
428
434
|
automaticDeliveryGraceMs?: number;
|
|
429
435
|
notify?: (notification: CoordinatorNotification) => void;
|