@signalridge/pi-subagents 1.9.0 → 1.10.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/CHANGELOG.md +81 -0
- package/README.md +46 -10
- package/package.json +2 -2
- package/src/cross-extension-rpc.ts +28 -5
- package/src/index.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,86 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.10.0
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
- f05185e: A workflow now runs at the width of the host's subagent pool rather than a
|
|
7
|
+
number it picked blind, and stops charging queue time against the agent waiting
|
|
8
|
+
in it.
|
|
9
|
+
|
|
10
|
+
Two facts were missing on the pi-workflows side, and they compounded. It had no
|
|
11
|
+
way to learn `maxConcurrent`, so its own width was a constant guess. And a
|
|
12
|
+
dispatched agent was treated as a running one, though a managed spawn takes a
|
|
13
|
+
background slot like any other and waits when the pool is full, so a batch
|
|
14
|
+
dispatched behind a busy pool could fail as timeouts with none of it having run.
|
|
15
|
+
|
|
16
|
+
- The ping reply can now carry the peer's live `maxConcurrent`, and pi-workflows
|
|
17
|
+
makes it both the default width and the ceiling: an unnamed `concurrency` takes
|
|
18
|
+
the pool size, and a named one is clamped to it, with the clamp recorded in the
|
|
19
|
+
run log so a caller who asked for a number learns why it got another. It is
|
|
20
|
+
read at each start and resume rather than frozen onto the run, because the pool
|
|
21
|
+
is a setting the user can change in between. One setting therefore governs the
|
|
22
|
+
whole fleet, which is what the ownership boundary already said and what the
|
|
23
|
+
code can now honour.
|
|
24
|
+
- **This narrows the common case, and that is the point.** The pool defaults to
|
|
25
|
+
4 background slots, so a default pair now runs a fan-out four at a time where
|
|
26
|
+
it used to dispatch `hardwareConcurrency - 2`. Nothing runs slower for it —
|
|
27
|
+
dispatching twelve into four slots only ever queued eight, ahead of the host's
|
|
28
|
+
own spawns — but the width a run reports is now the width it actually has, and
|
|
29
|
+
`maxConcurrent` is the single place to raise it for workflows and every other
|
|
30
|
+
background agent at once. A host that had already raised it gets the wide
|
|
31
|
+
fan-outs its setting always implied.
|
|
32
|
+
- The field is **requested by name** (`include: ["maxConcurrent"]`) rather than
|
|
33
|
+
volunteered. A ping envelope is parsed with `rejectUnknownKeys`, so a peer that
|
|
34
|
+
simply added a field would make every already-published caller reject the
|
|
35
|
+
handshake and lose workflows entirely. Asking keeps both directions working: an
|
|
36
|
+
older peer answers without it and the caller falls back to 16, and a caller that
|
|
37
|
+
never asks gets the v4 envelope unchanged. No version bump, no capability, no
|
|
38
|
+
lockstep release. Both READMEs document the mechanism, since `include` is now
|
|
39
|
+
part of the public cross-extension contract.
|
|
40
|
+
- `agentTimeoutMs` now counts from the moment the host reports the agent left its
|
|
41
|
+
queue. The clock is still armed at dispatch and restarted on that report, not
|
|
42
|
+
armed only by it, so a report that never arrives degrades to the old behaviour
|
|
43
|
+
rather than to an agent that can hang forever. A report that arrives *after* the
|
|
44
|
+
agent settled is ignored, so a late or duplicate one cannot leave a live timer
|
|
45
|
+
behind to abort a finished agent.
|
|
46
|
+
|
|
47
|
+
The engine already subscribed to `subagents:started` and already filtered it by
|
|
48
|
+
workflow owner, so the start signal is the event it was discarding.
|
|
49
|
+
|
|
50
|
+
`getMaxConcurrent` is required on the RPC bridge rather than optional. The other
|
|
51
|
+
bridge members are capabilities, advertised in the ping and rejected outright
|
|
52
|
+
when missing, so forgetting one fails loudly; a missing pool size has no
|
|
53
|
+
capability bit and no failure — the caller would silently fall back to its own
|
|
54
|
+
guess and run at the wrong width. Required is what makes that omission a compile
|
|
55
|
+
error instead of a quiet wrong answer.
|
|
56
|
+
|
|
57
|
+
### Patch Changes
|
|
58
|
+
|
|
59
|
+
- Updated dependencies [f05185e]
|
|
60
|
+
- @signalridge/pi-subagents-protocol@1.5.0
|
|
61
|
+
|
|
62
|
+
## 1.9.1
|
|
63
|
+
### Patch Changes
|
|
64
|
+
|
|
65
|
+
- fabe89a: Document how a workflow now reaches this catalogue.
|
|
66
|
+
|
|
67
|
+
The "One catalogue, including for workflows" section said a workflow script names
|
|
68
|
+
a key from `agentTiers` directly, with "no second workflow-tier vocabulary and no
|
|
69
|
+
mapping layer" and `agent({ tier: "low" })` examples. pi-workflows scripts now
|
|
70
|
+
name a *strength* and a table on that side chooses the tier, so every claim in
|
|
71
|
+
that section was inverted and its example is rejected before dispatch.
|
|
72
|
+
|
|
73
|
+
The claim it was protecting is still true and is now stated where it belongs:
|
|
74
|
+
there is no second tier catalogue and no second resolver, a `strengths` value is
|
|
75
|
+
a key in this catalogue and never carries its own `model`/`thinking`, and a
|
|
76
|
+
request arriving here is indistinguishable from a spawn that named the key
|
|
77
|
+
itself. Also notes that the shipped `low`/`medium`/`high` profiles are what
|
|
78
|
+
pi-workflows' default table maps onto, so renaming them leaves workflows on the
|
|
79
|
+
ordinary untiered path rather than breaking them.
|
|
80
|
+
|
|
81
|
+
Drops a stale pointer to `workflow.tiers`, a settings key this package no longer
|
|
82
|
+
reads.
|
|
83
|
+
|
|
3
84
|
## 1.9.0
|
|
4
85
|
### Minor Changes
|
|
5
86
|
|
package/README.md
CHANGED
|
@@ -443,16 +443,39 @@ defaults to the key; it is what the host reads when choosing between Agent tiers
|
|
|
443
443
|
|
|
444
444
|
### One catalogue, including for workflows
|
|
445
445
|
|
|
446
|
-
A managed `pi-workflows` call
|
|
447
|
-
There is no second
|
|
448
|
-
|
|
446
|
+
A managed `pi-workflows` call arrives naming a key from this same `agentTiers`
|
|
447
|
+
catalogue. There is no second tier catalogue and no second resolver: this package
|
|
448
|
+
still owns every model, every thinking level, and the only `resolveAgentTier()`.
|
|
449
|
+
|
|
450
|
+
What a workflow *script* writes is not that key. A script names a **strength** —
|
|
451
|
+
`low`, `medium`, `high`, pi-workflows' own word for how much effort a step
|
|
452
|
+
deserves — and a `strengths` table on that side chooses which of your tiers it
|
|
453
|
+
runs on:
|
|
449
454
|
|
|
450
455
|
```js
|
|
451
456
|
// in a workflow script
|
|
452
|
-
await agent("summarize this diff", {
|
|
453
|
-
await agent("design the migration", {
|
|
457
|
+
await agent("summarize this diff", { strength: "low" })
|
|
458
|
+
await agent("design the migration", { strength: "high" })
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
```jsonc
|
|
462
|
+
// pi-workflows' own settings, edited with `/workflows strength`
|
|
463
|
+
{ "strengths": { "low": "cheap-search", "high": "deep" } }
|
|
454
464
|
```
|
|
455
465
|
|
|
466
|
+
The indirection exists so that re-pricing workflow work does not re-price
|
|
467
|
+
everything else. Workflow fan-outs ask for cheap work by the dozen, and so does
|
|
468
|
+
the `Explore` agent and every spawn that names no tier of its own; if the
|
|
469
|
+
workflow side reached your catalogue directly, making a 26-agent fan-out
|
|
470
|
+
affordable would mean editing the tier all of them share. Pointing a strength
|
|
471
|
+
elsewhere leaves your tiers alone.
|
|
472
|
+
|
|
473
|
+
Nothing about that reaches this side. By the time a request arrives it carries
|
|
474
|
+
one tier key, and this package cannot tell a mapped call apart from a spawn that
|
|
475
|
+
named the key itself. Nor is it a second *policy*: a `strengths` value is a key
|
|
476
|
+
in this catalogue and never carries a `model` or `thinking` of its own — that is
|
|
477
|
+
the line between it and the retired `workflow.tiers` key, which did.
|
|
478
|
+
|
|
456
479
|
The tier is resolved by the same `resolveAgentTier()` path an ordinary Agent
|
|
457
480
|
spawn uses — same precedence, same model lookup, same thinking clamping, same
|
|
458
481
|
availability checks, same immutable resolution snapshot. A tier the host does not
|
|
@@ -464,9 +487,12 @@ could silently win or be silently ignored.
|
|
|
464
487
|
|
|
465
488
|
Fresh installs ship an effort ladder: `low`, `medium`, `high`. Every shipped
|
|
466
489
|
profile inherits its model, so a new machine gets a working vocabulary without
|
|
467
|
-
this package ever choosing a vendor for you.
|
|
468
|
-
|
|
469
|
-
|
|
490
|
+
this package ever choosing a vendor for you. Those names are also what
|
|
491
|
+
pi-workflows' shipped default table maps its strengths onto — identity, and only
|
|
492
|
+
where you define the name — so a stock machine runs workflows at the strengths
|
|
493
|
+
their scripts asked for. Rename or remove them and that default simply yields
|
|
494
|
+
nothing: a managed call that names no tier uses the agent's own tier, then
|
|
495
|
+
`agentTiers.defaultTier`, and finally falls back to `medium`.
|
|
470
496
|
|
|
471
497
|
`medium` inherits its model, so on an unconfigured machine that fallback runs on
|
|
472
498
|
the parent session's model. What it buys is a call with a *named* policy, a
|
|
@@ -577,7 +603,7 @@ the global file defines. The menu writes the merged catalogue back to the
|
|
|
577
603
|
project file, so deleting one of several works, but deleting the last one — or
|
|
578
604
|
clearing a `defaultTier` that only global sets — leaves no `agentTiers` key
|
|
579
605
|
behind, and the global value is inherited again on the next start. Remove it
|
|
580
|
-
from `~/.pi/agent/subagents.json` instead.
|
|
606
|
+
from `~/.pi/agent/subagents.json` instead.
|
|
581
607
|
|
|
582
608
|
### Refusals
|
|
583
609
|
|
|
@@ -645,7 +671,7 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
|
|
|
645
671
|
|
|
646
672
|
**Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `4`, default max turns unlimited, grace turns `5`, nested depth `2`, join mode `smart`, defaults enabled).
|
|
647
673
|
|
|
648
|
-
The `workflow` settings key is **retired
|
|
674
|
+
The `workflow` settings key is **retired**; a file that still has one is ignored with a warning naming the key. This file holds no workflow routing of its own: a managed `pi-workflows` call arrives naming a key from `agentTiers`, and which key that is was decided on the pi-workflows side by its own `strengths` table — a table of keys into this catalogue, never a second catalogue and never its own `model`/`thinking`. `agentTiers.defaultTier` replaces what `workflow.defaultTier` used to do. See [One catalogue, including for workflows](#one-catalogue-including-for-workflows).
|
|
649
675
|
|
|
650
676
|
**Default model** (`defaultModel`, unset): the model a non-tiered ordinary subagent runs — see [`defaultModel`](#defaultmodel) for where it sits in precedence, why an unresolvable value falls back instead of failing, and how `"inherit"` lets a project cancel a global default. **Default tier** (`agentTiers.defaultTier`, unset) is the tier applied when neither the caller nor the agent names one; the profiles it selects from live under [`agentTiers`](#model-tiers). It has three settings — a tier name, `unset`, and `none` — which the menu offers separately because the last two behave differently for managed workflow calls; see [Model tiers](#model-tiers) for the table.
|
|
651
677
|
|
|
@@ -741,6 +767,16 @@ const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (reply) => {
|
|
|
741
767
|
pi.events.emit("subagents:rpc:ping", { requestId });
|
|
742
768
|
```
|
|
743
769
|
|
|
770
|
+
The reply always carries `version`, `capabilities`, and `routingPolicy`. Anything beyond those is **requested by name** through an optional `include` array, and is sent only to a caller that asked:
|
|
771
|
+
|
|
772
|
+
```typescript
|
|
773
|
+
pi.events.emit("subagents:rpc:ping", { requestId, include: ["maxConcurrent"] });
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
`include` accepts up to 8 names; unknown ones are ignored. Today the only one is `maxConcurrent`, the live background-agent pool size — the number of background slots a spawn competes for, which `/subagents` can change mid-session, so read it per operation rather than caching it at session start.
|
|
777
|
+
|
|
778
|
+
Opt-in rather than volunteered, because the envelope is validated with `rejectUnknownKeys`: a field added unconditionally would make every already-published caller reject the handshake and lose the peer entirely. Asking keeps both directions working without a version bump — an older peer answers without the field, and a caller that never asks gets the same envelope it has always parsed. Treat an absent field as "this peer does not publish it" and fall back to your own default; it is not an error.
|
|
779
|
+
|
|
744
780
|
### Managed spawn (protocol v4)
|
|
745
781
|
|
|
746
782
|
Workflow-owned orchestration uses the `subagents:rpc:spawn-managed` channel. Its request may include the core identity fields plus an optional Agent `tier`, `toolset`, `excludeTools`, `thread`, and `isolation: "worktree"`. There is no per-call `model` or `thinking` — the wire validator rejects them:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-subagents",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
|
|
5
5
|
"author": "tintinweb and signalridge contributors",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@sinclair/typebox": "^0.34.50",
|
|
34
34
|
"croner": "^10.0.1",
|
|
35
|
-
"@signalridge/pi-subagents-protocol": "^1.
|
|
35
|
+
"@signalridge/pi-subagents-protocol": "^1.5.0",
|
|
36
36
|
"@signalridge/pi-ui": "^1.3.0",
|
|
37
37
|
"nanoid": "^5.0.0"
|
|
38
38
|
},
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
PROTOCOL_CAPABILITIES,
|
|
13
13
|
PROTOCOL_VERSION,
|
|
14
14
|
parseManagedSpawnRequest,
|
|
15
|
+
parsePingIncludes,
|
|
15
16
|
} from "@signalridge/pi-subagents-protocol";
|
|
16
17
|
|
|
17
18
|
export { CHILD_CONTEXT_CAPABILITY, PROTOCOL_CAPABILITIES, PROTOCOL_VERSION };
|
|
@@ -44,6 +45,17 @@ export interface SpawnCapable {
|
|
|
44
45
|
abortOwned?(id: string, owner: AgentOwner): boolean;
|
|
45
46
|
quiesceOwned?(runId: string, agentIds: string[], timeoutMs: number, owners?: AgentOwner[]): Promise<{ settled: boolean; pending: string[] }>;
|
|
46
47
|
reconcileManaged?(spawnKey: string, owner: AgentOwner): ManagedSpawnResult | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Live background-agent pool size, published to a peer that asks for it.
|
|
50
|
+
*
|
|
51
|
+
* Required, unlike the capability members above. Those are advertised in the
|
|
52
|
+
* ping's `capabilities` and a peer missing one is rejected outright, so a
|
|
53
|
+
* bridge that forgets to forward one fails loudly. A missing pool size has no
|
|
54
|
+
* capability bit and no failure: the caller would silently fall back to its
|
|
55
|
+
* own guess and run at the wrong width. Making it required is what stops a
|
|
56
|
+
* bridge from omitting it by accident.
|
|
57
|
+
*/
|
|
58
|
+
getMaxConcurrent(): number;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
export interface RpcDeps {
|
|
@@ -170,11 +182,22 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
170
182
|
const { events, pi, getCtx, manager } = deps;
|
|
171
183
|
const getRoutingPolicy = deps.getRoutingPolicy ?? getRoutingPolicySnapshot;
|
|
172
184
|
|
|
173
|
-
const unsubPing = handleRpc(events, "subagents:rpc:ping", () =>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
const unsubPing = handleRpc(events, "subagents:rpc:ping", (params) => {
|
|
186
|
+
// Additive fields are opt-in by name. The reply envelope is parsed with
|
|
187
|
+
// rejectUnknownKeys on the caller's side, so volunteering a field would
|
|
188
|
+
// make every already-published peer reject the handshake; a peer that asks
|
|
189
|
+
// for one is by construction a peer that knows how to parse it.
|
|
190
|
+
const include = parsePingIncludes(params.include);
|
|
191
|
+
const maxConcurrent = include.has("maxConcurrent") ? manager.getMaxConcurrent() : undefined;
|
|
192
|
+
return {
|
|
193
|
+
version: PROTOCOL_VERSION,
|
|
194
|
+
capabilities: PROTOCOL_CAPABILITIES,
|
|
195
|
+
routingPolicy: getRoutingPolicy(),
|
|
196
|
+
...(typeof maxConcurrent === "number" && Number.isInteger(maxConcurrent) && maxConcurrent >= 1
|
|
197
|
+
? { maxConcurrent }
|
|
198
|
+
: {}),
|
|
199
|
+
};
|
|
200
|
+
});
|
|
178
201
|
|
|
179
202
|
const unsubSpawn = handleRpc(events, "subagents:rpc:spawn", (params) => {
|
|
180
203
|
const ctx = getCtx();
|
package/src/index.ts
CHANGED
|
@@ -1234,6 +1234,10 @@ function activateRootRuntime(
|
|
|
1234
1234
|
quiesceOwned: (runId, agentIds, timeoutMs, owners) =>
|
|
1235
1235
|
manager.quiesceOwned(runId, agentIds, timeoutMs, owners),
|
|
1236
1236
|
reconcileManaged: (spawnKey, owner) => manager.reconcileManaged(spawnKey, owner),
|
|
1237
|
+
// The live pool size, not a snapshot: `/subagents` can change it
|
|
1238
|
+
// mid-session, and a peer that sizes its fan-out from this must see
|
|
1239
|
+
// the value that is actually throttling it now.
|
|
1240
|
+
getMaxConcurrent: () => manager.getMaxConcurrent(),
|
|
1237
1241
|
},
|
|
1238
1242
|
});
|
|
1239
1243
|
// Emit only after RPC handlers are armed, and only for a bound session.
|