@signalridge/pi-subagents 1.9.1 → 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 CHANGED
@@ -1,5 +1,64 @@
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
+
3
62
  ## 1.9.1
4
63
  ### Patch Changes
5
64
 
package/README.md CHANGED
@@ -767,6 +767,16 @@ const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (reply) => {
767
767
  pi.events.emit("subagents:rpc:ping", { requestId });
768
768
  ```
769
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
+
770
780
  ### Managed spawn (protocol v4)
771
781
 
772
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.9.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.4.0",
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
- version: PROTOCOL_VERSION,
175
- capabilities: PROTOCOL_CAPABILITIES,
176
- routingPolicy: getRoutingPolicy(),
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.