@signalridge/pi-workflows 1.6.0 → 1.7.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 +113 -0
- package/README.md +25 -2
- package/package.json +2 -2
- package/skills/workflow-authoring/SKILL.md +23 -4
- package/skills/workflow-authoring/examples/defensive-json-parsing.js +1 -1
- package/skills/workflow-authoring/examples/phased-budgets.js +1 -1
- package/skills/workflow-authoring/examples/saved-nested-workflows.js +1 -1
- package/skills/workflow-authoring/examples/structured-output.js +1 -1
- package/skills/workflow-authoring/references/capabilities.md +4 -4
- package/skills/workflow-authoring/references/capability-details.md +12 -6
- package/skills/workflow-authoring/references/lifecycle.md +5 -1
- package/skills/workflow-authoring/references/review.md +4 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +6 -4
- package/src/capabilities.ts +15 -5
- package/src/engine.ts +34 -0
- package/src/index.ts +41 -10
- package/src/rpc-client.ts +14 -1
- package/src/runtime.ts +117 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,118 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.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
|
+
- f05185e: Stop the run's own limits from being the thing that keeps a fan-out small.
|
|
57
|
+
|
|
58
|
+
Every execution limit a caller can pass — `maxAgents`, `concurrency`,
|
|
59
|
+
`agentRetries`, `tokenBudget`, `agentTimeoutMs` — reached the model as a bare
|
|
60
|
+
number with no stated default, while the authoring skill asked it to "set finite
|
|
61
|
+
bounds that match the work" for three of them. The result was a caller that
|
|
62
|
+
priced the run before writing it: fan-outs of one to eight, `concurrency: 1`,
|
|
63
|
+
and per-agent wall clocks of two to five minutes. Each of those values ends a run
|
|
64
|
+
rather than shaping it — `AGENT_LIMIT_EXCEEDED` partway through, or a whole
|
|
65
|
+
batch failing as timeouts — so the precaution removed coverage and not cost.
|
|
66
|
+
|
|
67
|
+
Four changes, and the first two matter most:
|
|
68
|
+
|
|
69
|
+
- Every numeric tool input now states its default and says to omit it, and the
|
|
70
|
+
guidelines ask for a fan-out sized to the evidence the task has: one agent per
|
|
71
|
+
independent question, ten to thirty for an ordinary review or audit. The gain
|
|
72
|
+
named is coverage, not speed — one agent asked one question reads further into
|
|
73
|
+
it than one agent asked four — because how many of them run at once is the
|
|
74
|
+
host's pool and not the script's to decide. The authoring and review skills no
|
|
75
|
+
longer treat the invocation limits as the place to express a bound — that
|
|
76
|
+
belongs to the script's own task counts, loop rounds, and fan-out widths, which
|
|
77
|
+
is where the topology is decided. `lifecycle.md` also stops citing two settings
|
|
78
|
+
keys that never existed (`defaultAgentTimeoutMs`, `defaultTokenBudget`).
|
|
79
|
+
- `agentTimeoutMs` defaults to 30 minutes rather than 5. It exists to bound a
|
|
80
|
+
hang; an agent's real budget is the turn, tool and token ceiling pi-subagents
|
|
81
|
+
applies to it, which a deep call legitimately spends over many minutes. A
|
|
82
|
+
default an order of magnitude under that budget turned every wide fan-out of
|
|
83
|
+
real work into a batch of timeouts. `null` still removes the wall clock.
|
|
84
|
+
- Concurrency defaults to 16 and clamps at 64, up from a default of
|
|
85
|
+
`hardwareConcurrency - 2` clamped at 16. Core count was the wrong quantity: a
|
|
86
|
+
dispatched agent is a remote call this process waits on, so the old default
|
|
87
|
+
fanned a 14-core host out 12 ways and a 4-core host 2 ways for identical work.
|
|
88
|
+
pi-subagents' `maxConcurrent` remains the authoritative throttle — this is the
|
|
89
|
+
guard against a runaway script, and a host that wants a narrower fleet still
|
|
90
|
+
narrows it in one place for every agent it runs.
|
|
91
|
+
- `strength` gains a criterion instead of three bare words. `high` now means a
|
|
92
|
+
step whose error is expensive to reverse — an architecture decision, a
|
|
93
|
+
destructive migration, a security boundary — normally absent from a fan-out and
|
|
94
|
+
at most one closing step, and the authoring skill and review checklist both say
|
|
95
|
+
so. The ULTRA effort directive stops asking for it outright: it used to end
|
|
96
|
+
`give the deepest steps strength: "high"`, which priced every branch of a wide
|
|
97
|
+
run at the cost of the one that mattered, and now points the exhaustiveness at
|
|
98
|
+
width and reserves `high` for a step that has that property, if the run has one.
|
|
99
|
+
Nothing about the mechanism changed — there is still no default strength, and a
|
|
100
|
+
call naming none still dispatches with no tier.
|
|
101
|
+
- `/workflows run` plans up to 24 tasks instead of 8. The planner's JSON schema
|
|
102
|
+
was the only bound on the one path where the model decides the width itself,
|
|
103
|
+
and 8 was below what an ordinary audit decomposes into. The synthesis step
|
|
104
|
+
absorbs the extra tasks by shrinking its per-worker excerpt: measured against
|
|
105
|
+
the 78,000-character context budget, 8 tasks contribute about 6,000 characters
|
|
106
|
+
each and 24 contribute about 2,500 — three times the coverage at roughly 40%
|
|
107
|
+
of the depth per worker, and about a quarter more total evidence reaching the
|
|
108
|
+
synthesizer. Widening past that would need a larger budget, which the wire's
|
|
109
|
+
100,000-character prompt cap does not leave room for.
|
|
110
|
+
|
|
111
|
+
### Patch Changes
|
|
112
|
+
|
|
113
|
+
- Updated dependencies [f05185e]
|
|
114
|
+
- @signalridge/pi-subagents-protocol@1.5.0
|
|
115
|
+
|
|
3
116
|
## 1.6.0
|
|
4
117
|
### Minor Changes
|
|
5
118
|
|
package/README.md
CHANGED
|
@@ -145,8 +145,31 @@ workflow, and the review skill before accepting a topology or recovery change.
|
|
|
145
145
|
## Tools and commands
|
|
146
146
|
|
|
147
147
|
- `workflow`: run a script (`script`) or a saved/built-in workflow (`name`),
|
|
148
|
-
with `args`, `background` (default true), `maxAgents
|
|
149
|
-
`
|
|
148
|
+
with `args`, `background` (default true), `maxAgents` (default 1000),
|
|
149
|
+
`concurrency` (defaults to the host's pool size), `agentRetries` (default 0),
|
|
150
|
+
`tokenBudget` (default unlimited), `agentTimeoutMs` (default 1800000, `null`
|
|
151
|
+
disables), and `resumeFromRunId`. The execution limits are tuned defaults: a
|
|
152
|
+
caller that names one narrower than the script needs stops the run rather
|
|
153
|
+
than shaping it.
|
|
154
|
+
|
|
155
|
+
Concurrency is not configured here at all. A workflow agent occupies one of
|
|
156
|
+
pi-subagents' `maxConcurrent` background slots like any other background
|
|
157
|
+
agent, so that setting is both the width a run defaults to and the ceiling it
|
|
158
|
+
is clamped to — read live from the peer at each start and resume, so one
|
|
159
|
+
setting governs the whole fleet and a run neither guesses it nor exceeds it.
|
|
160
|
+
The peer publishes it only to a caller that requests it by name, so an older
|
|
161
|
+
pair still handshakes; a run that learns no pool size falls back to 16. A
|
|
162
|
+
request above the pool is clamped and the run log says so.
|
|
163
|
+
|
|
164
|
+
In practice that pool is usually the *narrower* number, not the wider one: it
|
|
165
|
+
defaults to 4 background slots, so a default pair runs a fan-out four at a
|
|
166
|
+
time. That is the honest width rather than a new restriction — dispatching
|
|
167
|
+
twelve into four slots only ever queued eight — but it does mean a wide
|
|
168
|
+
fan-out finishes in waves, and `maxConcurrent` is the one place to change
|
|
169
|
+
that, for workflows and for every other background agent at once.
|
|
170
|
+
|
|
171
|
+
`agentTimeoutMs` counts from the moment the host reports the agent left its
|
|
172
|
+
queue, so waiting for a slot is never charged against the work.
|
|
150
173
|
- `workflow_control`: `list`, `get`, `pause`, `resume`, `stop`, or `rm` a run; `rm` writes a durable removal tombstone.
|
|
151
174
|
- `pi.events` emits `pi-workflows:runtime` snapshots (`{ runId, event }`) for phase, task, quality, retry, and nested-workflow progress; observer failures are contained.
|
|
152
175
|
- `/workflows`: TUI navigator plus `run`, `status`, `watch`, `stop`, `pause`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-workflows",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"@signalridge/pi-subagents": ">=1.9.0"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@signalridge/pi-subagents-protocol": "^1.
|
|
23
|
+
"@signalridge/pi-subagents-protocol": "^1.5.0",
|
|
24
24
|
"@sinclair/typebox": "^0.34.50",
|
|
25
25
|
"acorn": "^8.16.0"
|
|
26
26
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: workflow-authoring
|
|
3
3
|
description: Design, write, review, and debug Pi JavaScript workflows that coordinate multiple subagents. Use when a request needs named dependencies, bounded fan-out, per-item pipelines, quality gates, saved or nested workflows, pause/resume, or workflow progress; do not use it merely to run an existing named workflow.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.7.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Workflow authoring
|
|
@@ -25,9 +25,19 @@ JavaScript.
|
|
|
25
25
|
- Give every work unit a stable ID and every agent call a short unique label.
|
|
26
26
|
Keep IDs beside results before filtering; `null` is missing coverage, not a
|
|
27
27
|
successful finding.
|
|
28
|
-
- Bound graph tasks, fan-out items,
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
- Bound the algorithm inside the script: graph tasks, fan-out items, loop
|
|
29
|
+
rounds, semantic retries, and evidence size. The invocation-level limits
|
|
30
|
+
(`maxAgents`, `concurrency`, `agentRetries`, `tokenBudget`, `agentTimeoutMs`)
|
|
31
|
+
are tuned defaults rather than the place to express a bound; each one stops or
|
|
32
|
+
fails a run that outgrows it, so a precautionary value cuts the coverage and
|
|
33
|
+
not the cost. Set one only when the user asked for that limit.
|
|
34
|
+
- Size the fan-out to the evidence the task actually has: one agent per
|
|
35
|
+
independent question, ten to thirty for an ordinary review or audit. The gain
|
|
36
|
+
is coverage — one agent asked one question reads further into it than one
|
|
37
|
+
agent asked four. Speed is a separate matter and not yours to decide: how many
|
|
38
|
+
of those agents run at once is the host's `maxConcurrent` pool, so a wide
|
|
39
|
+
fan-out on a narrow pool still finishes in waves. Split for what gets looked
|
|
40
|
+
at, not for wall-clock.
|
|
31
41
|
- Route an agent call with `strength: "low" | "medium" | "high"` — this
|
|
32
42
|
package's own word for how much effort a step deserves, and the only routing
|
|
33
43
|
word a workflow has. It is **not** an agent-tier key: a `strengths` table
|
|
@@ -44,6 +54,15 @@ JavaScript.
|
|
|
44
54
|
documented keys. Never guess an agent-type name—use
|
|
45
55
|
names supplied by the current environment and follow
|
|
46
56
|
[registry ownership](references/registry-ownership.md).
|
|
57
|
+
- Pick that word by what a wrong answer costs, not by how important the step
|
|
58
|
+
feels. `low` is retrieval, navigation, and read-heavy breadth. `medium` is the
|
|
59
|
+
ordinary working rung and where a labelled step belongs unless you can say why
|
|
60
|
+
not. `high` is for a step whose error is expensive to reverse — an architecture
|
|
61
|
+
decision, a destructive migration, a security boundary — and a fan-out normally
|
|
62
|
+
names it zero times, at most once on a closing step that actually decides
|
|
63
|
+
something. It is not a reward for the hardest-sounding task: routing a whole
|
|
64
|
+
fan-out to `high` prices every branch at the cost of the one that mattered, and
|
|
65
|
+
buys depth where the run needed coverage.
|
|
47
66
|
- Workflows have no imports, filesystem/network APIs, timers, or unrestricted
|
|
48
67
|
Node APIs. Pass timestamps, randomness, and external decisions through `args`.
|
|
49
68
|
|
|
@@ -31,7 +31,7 @@ function parseOrFlag(text, requiredKeys) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// ADAPT: validate and bound work, then keep required fields as small as downstream JavaScript needs.
|
|
34
|
-
const work = args && Array.isArray(args.work) ? args.work.slice(0,
|
|
34
|
+
const work = args && Array.isArray(args.work) ? args.work.slice(0, 24) : [{ id: "sample" }];
|
|
35
35
|
const requiredKeys = ["verdict", "reason"];
|
|
36
36
|
const outputs = [];
|
|
37
37
|
const missing = [];
|
|
@@ -5,7 +5,7 @@ export const meta = {
|
|
|
5
5
|
};
|
|
6
6
|
|
|
7
7
|
// ADAPT: validate the work and choose phase budgets, prompts, and schemas; add invocation-level tokenBudget only when the user explicitly requests a cap.
|
|
8
|
-
const work = args && Array.isArray(args.work) ? args.work.slice(0,
|
|
8
|
+
const work = args && Array.isArray(args.work) ? args.work.slice(0, 24) : [{ id: "sample" }];
|
|
9
9
|
const phaseBudget =
|
|
10
10
|
args && Number.isFinite(args.phaseBudget) ? Math.max(1, Math.min(args.phaseBudget, 100000)) : 2000;
|
|
11
11
|
const resultSchema = {
|
|
@@ -7,7 +7,7 @@ export const meta = {
|
|
|
7
7
|
// ADAPT: accept only a saved workflow name supplied in context; never guess an installed name.
|
|
8
8
|
const savedWorkflowName = args && typeof args.savedWorkflowName === "string" ? args.savedWorkflowName : null;
|
|
9
9
|
if (!savedWorkflowName) throw new Error("args.savedWorkflowName must be supplied by context");
|
|
10
|
-
const jobs = args && Array.isArray(args.jobs) ? args.jobs.slice(0,
|
|
10
|
+
const jobs = args && Array.isArray(args.jobs) ? args.jobs.slice(0, 24) : [{ id: "sample" }];
|
|
11
11
|
const preparationSchema = {
|
|
12
12
|
type: "object",
|
|
13
13
|
properties: { ready: { type: "boolean" } },
|
|
@@ -5,7 +5,7 @@ export const meta = {
|
|
|
5
5
|
};
|
|
6
6
|
|
|
7
7
|
// ADAPT: validate and bound work, then keep the schema as small as downstream JavaScript needs.
|
|
8
|
-
const work = args && Array.isArray(args.work) ? args.work.slice(0,
|
|
8
|
+
const work = args && Array.isArray(args.work) ? args.work.slice(0, 24) : [{ id: "sample" }];
|
|
9
9
|
const outputSchema = {
|
|
10
10
|
type: "object",
|
|
11
11
|
properties: {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Workflow capability index
|
|
3
3
|
|
|
4
4
|
Contract format: `2.0.0`<br>
|
|
5
|
-
Contract content / skill / extension: `1.
|
|
5
|
+
Contract content / skill / extension: `1.7.0`
|
|
6
6
|
|
|
7
7
|
This compact generated index covers supported runtime globals and workflow-tool inputs. For constraints, compatibility behavior, internal boundaries, and dynamic-reference ownership, follow the [exhaustive generated facts](capability-details.md).
|
|
8
8
|
|
|
@@ -11,7 +11,7 @@ This compact generated index covers supported runtime globals and workflow-tool
|
|
|
11
11
|
<!-- BEGIN GENERATED SUPPORTED WORKFLOW CAPABILITIES -->
|
|
12
12
|
| Name | Classification | Signature | Options and defaults |
|
|
13
13
|
| --- | --- | --- | --- |
|
|
14
|
-
| agent | runtime-global | `agent(prompt, options?) => Promise<string \| structured value \| null>` | `label`: string (optional); default: derived from phase and call count<br>`phase`: string (optional); default: current phase<br>`schema`: plain JSON Schema (optional)<br>`strength`: "low" \| "medium" \| "high" (optional); default: no tier is sent; the agent's own tier, else agentTiers.defaultTier<br>`isolation`: "worktree" (optional); default: pi-subagents worktree policy<br>`thread`: string (optional); default: fresh session per call<br>`toolset`: string (optional); default: configured host/agent toolset hint<br>`excludeTools`: string[] (optional)<br>`agentType`: string (optional); default: general-purpose<br>`timeoutMs`: number \| null (optional); default:
|
|
14
|
+
| agent | runtime-global | `agent(prompt, options?) => Promise<string \| structured value \| null>` | `label`: string (optional); default: derived from phase and call count<br>`phase`: string (optional); default: current phase<br>`schema`: plain JSON Schema (optional)<br>`strength`: "low" \| "medium" \| "high" (optional); default: no tier is sent; the agent's own tier, else agentTiers.defaultTier<br>`isolation`: "worktree" (optional); default: pi-subagents worktree policy<br>`thread`: string (optional); default: fresh session per call<br>`toolset`: string (optional); default: configured host/agent toolset hint<br>`excludeTools`: string[] (optional)<br>`agentType`: string (optional); default: general-purpose<br>`timeoutMs`: number \| null (optional); default: 1800000 (30min); null disables<br>`retries`: number (optional); default: run retry count |
|
|
15
15
|
| parallel | runtime-global | `parallel(thunks) => Promise<Array<unknown \| null>>` | — |
|
|
16
16
|
| pipeline | runtime-global | `pipeline(items, ...stages) => Promise<Array<unknown \| null>>` | — |
|
|
17
17
|
| orchestrate | runtime-global | `orchestrate(tasks, options?) => Promise<{ results, tasks }>` | `onError`: "skip-dependents"\|"continue"\|"fail-fast" (optional); default: "skip-dependents" |
|
|
@@ -35,9 +35,9 @@ This compact generated index covers supported runtime globals and workflow-tool
|
|
|
35
35
|
| args | workflow-tool-input | `args?: unknown` | — |
|
|
36
36
|
| background | workflow-tool-input | `background?: boolean = true` | — |
|
|
37
37
|
| maxAgents | workflow-tool-input | `maxAgents?: number = 1000` | — |
|
|
38
|
-
| concurrency | workflow-tool-input | `concurrency?: number` | — |
|
|
38
|
+
| concurrency | workflow-tool-input | `concurrency?: number = the host's pi-subagents maxConcurrent` | — |
|
|
39
39
|
| agentRetries | workflow-tool-input | `agentRetries?: number = 0` | — |
|
|
40
|
-
| agentTimeoutMs | workflow-tool-input | `agentTimeoutMs?: number =
|
|
40
|
+
| agentTimeoutMs | workflow-tool-input | `agentTimeoutMs?: number = 1800000` | — |
|
|
41
41
|
| tokenBudget | workflow-tool-input | `tokenBudget?: number` | — |
|
|
42
42
|
| resumeFromRunId | workflow-tool-input | `resumeFromRunId?: string` | — |
|
|
43
43
|
| export const meta | script-contract | `export const meta = { name, description, phases? }` | — |
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Exhaustive workflow capability facts
|
|
3
3
|
|
|
4
4
|
Contract format: `2.0.0`<br>
|
|
5
|
-
Contract content / skill / extension: `1.
|
|
5
|
+
Contract content / skill / extension: `1.7.0`
|
|
6
6
|
|
|
7
7
|
Every exact fact below is projected from the installed extension's capability contract. Explanatory judgment belongs in the hand-written references next to this file.
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ Every exact fact below is projected from the installed extension's capability co
|
|
|
22
22
|
- `toolset`: string (optional); default: configured host/agent toolset hint
|
|
23
23
|
- `excludeTools`: string[] (optional)
|
|
24
24
|
- `agentType`: string (optional); default: general-purpose
|
|
25
|
-
- `timeoutMs`: number \| null (optional); default:
|
|
25
|
+
- `timeoutMs`: number \| null (optional); default: 1800000 (30min); null disables
|
|
26
26
|
- `retries`: number (optional); default: run retry count
|
|
27
27
|
- Constraint: recoverable failures return null after retries; nonrecoverable failures throw
|
|
28
28
|
- Constraint: schema noncompliance after bounded repair attempts is nonrecoverable
|
|
@@ -262,8 +262,12 @@ Every exact fact below is projected from the installed extension's capability co
|
|
|
262
262
|
|
|
263
263
|
- Classification: `workflow-tool-input`
|
|
264
264
|
- Support: `supported`
|
|
265
|
-
- Signature: `concurrency?: number`
|
|
266
|
-
- Constraint: runtime clamps to 1..
|
|
265
|
+
- Signature: `concurrency?: number = the host's pi-subagents maxConcurrent`
|
|
266
|
+
- Constraint: runtime clamps to 1..64
|
|
267
|
+
- Constraint: read live from the peer at each start and resume, and also the ceiling: a request above the pool is clamped to it, and the run log records the clamp
|
|
268
|
+
- Constraint: 16 is only the fallback for a peer that publishes no pool size
|
|
269
|
+
- Constraint: pi-subagents maxConcurrent stays the authoritative throttle; one setting governs the whole fleet
|
|
270
|
+
- Constraint: that pool is typically small (4 slots by default), so a wide fan-out runs in waves rather than all at once
|
|
267
271
|
|
|
268
272
|
<a id="workflow-tool-input-agentretries"></a>
|
|
269
273
|
## agentRetries
|
|
@@ -278,8 +282,10 @@ Every exact fact below is projected from the installed extension's capability co
|
|
|
278
282
|
|
|
279
283
|
- Classification: `workflow-tool-input`
|
|
280
284
|
- Support: `supported`
|
|
281
|
-
- Signature: `agentTimeoutMs?: number =
|
|
282
|
-
- Constraint: null disables the hard timeout; default
|
|
285
|
+
- Signature: `agentTimeoutMs?: number = 1800000`
|
|
286
|
+
- Constraint: null disables the hard timeout; default 1800000ms (30min)
|
|
287
|
+
- Constraint: it bounds a hang only; an agent's real budget is the host's turn, tool and token ceiling
|
|
288
|
+
- Constraint: the clock restarts when the host reports the agent left its queue, so waiting for a slot is not charged as work
|
|
283
289
|
|
|
284
290
|
<a id="workflow-tool-input-tokenbudget"></a>
|
|
285
291
|
## tokenBudget
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Bounds and budget
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Bound the work where the work is decided: graph tasks, fan-out items, loop rounds, semantic retries, and evidence size, all inside the script. The invocation-level limits are not that place. `maxAgents` (1000), `concurrency` (the host's pool, and 16 only when it publishes none, clamped either way to 64), `agentRetries` (0) and `agentTimeoutMs` (1800000ms, `null` to disable) are tuned defaults, and `tokenBudget` is unlimited unless set; each one stops or fails a run that outgrows it, so a precautionary value removes coverage rather than cost. Pass one only when the user asked for that limit — a `maxAgents` under the script's own dispatch count ends the run with `AGENT_LIMIT_EXCEEDED` partway, and an `agentTimeoutMs` shorter than the host's turn/tool/token budget fails a whole fan-out as timeouts. A journal replay is logical work and does not consume the real-dispatch `maxAgents` cap, but it still counts toward reported call totals. If the user does ask for a token cap, allow for every planned agent call, retry, synthesis, and verification pass, with headroom: a tight gate terminates coverage and does not reduce work already in flight.
|
|
6
|
+
|
|
7
|
+
`concurrency` needs no value at all: it defaults to the host's pi-subagents `maxConcurrent`, read live at each start and resume, and a larger request is clamped to it (the run log records the clamp). Dispatching wider would not run wider — a workflow agent takes one of those background slots like any other, so the surplus would only wait in the host's queue, ahead of the host's own spawns. Name a number here only to run *narrower* than the host allows, and only when the user asked for that.
|
|
8
|
+
|
|
9
|
+
That pool is typically small — 4 slots unless the host was configured otherwise — so assume a wide fan-out runs in waves, not all at once. This does not change how wide to make it: the agents all run either way, and the reason to split work is coverage rather than wall-clock. It does mean a script should not promise the user a fast answer because it fanned out, and should not add a checkpoint or a progress phase that only makes sense if everything starts together.
|
|
6
10
|
|
|
7
11
|
Enter a phase budget with `phase("Name", { budget: N })`; phase metadata does not carry budgets. `N` is a token allowance, not a call or round count: size it for the intended agent work instead of copying a small iteration limit. Token and phase budgets are soft pre-call gates. Spend lands after agents finish, so concurrent work can overshoot. A phase budget gates later calls in that phase; it neither reserves tokens nor cancels active calls. `budget.spent()` and `budget.remaining()` include nested work.
|
|
8
12
|
|
|
@@ -30,7 +30,10 @@ Review author-visible behavior, not formatting preferences. When behavior depend
|
|
|
30
30
|
## Lifecycle
|
|
31
31
|
|
|
32
32
|
- Are runtime retries and semantic retries separately bounded?
|
|
33
|
-
- Are loops,
|
|
33
|
+
- Are loops, graph tasks, fan-out items, and semantic retries bounded inside the script, where the topology is decided?
|
|
34
|
+
- Does the invocation leave `maxAgents`, `concurrency`, `agentRetries`, `tokenBudget` and `agentTimeoutMs` at their defaults unless the user asked for that limit? A precautionary value there removes coverage, not cost.
|
|
35
|
+
- Is the fan-out as wide as the evidence the task has, rather than narrowed by habit?
|
|
36
|
+
- Does `high` appear only on a step whose error is expensive to reverse — normally nowhere, at most once — rather than across a fan-out?
|
|
34
37
|
- Are budget claims honest about soft gates and in-flight overshoot?
|
|
35
38
|
- Are checkpoints limited to implemented confirmation/headless behavior?
|
|
36
39
|
- Does nesting stay one level, allow only safe sibling concurrency, and account for shared limits/store?
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: workflow-patterns
|
|
3
3
|
description: Choose and run one of Pi's built-in multi-agent workflows for research, adversarial review, code review, multi-perspective analysis, or codebase auditing. Use when the request matches a named pattern; use workflow-authoring instead for a new script or custom topology.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.7.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Built-in workflow patterns
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: workflow-review
|
|
3
3
|
description: Audit an existing Pi JavaScript workflow for topology, prompt/data flow, limits, failure handling, cancellation, resume safety, and publication compatibility. Use when reviewing or debugging a workflow script or saved run; do not use to run a standard named workflow.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "1.
|
|
5
|
+
version: "1.7.0"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Workflow review
|
|
@@ -25,9 +25,11 @@ they are available.
|
|
|
25
25
|
values and their bounds. Confirm that structured fields have a schema and
|
|
26
26
|
that failed/null results remain visible in a ledger.
|
|
27
27
|
4. **Check admission.** Verify finite bounds for task count, fan-out, loops,
|
|
28
|
-
retries,
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
retries, and evidence size inside the script, and that the invocation leaves
|
|
29
|
+
`maxAgents`, `concurrency`, `tokenBudget` and `agentTimeoutMs` at their
|
|
30
|
+
defaults unless the user asked for that limit — a precautionary value there
|
|
31
|
+
removes coverage rather than cost. Check that a graph validates IDs,
|
|
32
|
+
references, duplicate edges, and cycles before any callback runs.
|
|
31
33
|
5. **Check failure policy.** Distinguish recoverable child `null` from fatal
|
|
32
34
|
workflow errors. For graphs, verify whether the default
|
|
33
35
|
`skip-dependents`, explicit `continue`, or `fail-fast` policy matches the
|
package/src/capabilities.ts
CHANGED
|
@@ -63,7 +63,7 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
|
|
|
63
63
|
{ name: "toolset", kind: "string", optional: true, default: "configured host/agent toolset hint" },
|
|
64
64
|
{ name: "excludeTools", kind: "string[]", optional: true, default: "none" },
|
|
65
65
|
{ name: "agentType", kind: "string", optional: true, default: "general-purpose" },
|
|
66
|
-
{ name: "timeoutMs", kind: "number | null", optional: true, default: "
|
|
66
|
+
{ name: "timeoutMs", kind: "number | null", optional: true, default: "1800000 (30min); null disables" },
|
|
67
67
|
{ name: "retries", kind: "number", optional: true, default: "run retry count" },
|
|
68
68
|
],
|
|
69
69
|
constraints: [
|
|
@@ -284,9 +284,15 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
|
|
|
284
284
|
{
|
|
285
285
|
name: "concurrency",
|
|
286
286
|
classification: "workflow-tool-input",
|
|
287
|
-
signature: "concurrency?: number",
|
|
287
|
+
signature: "concurrency?: number = the host's pi-subagents maxConcurrent",
|
|
288
288
|
options: [],
|
|
289
|
-
constraints: [
|
|
289
|
+
constraints: [
|
|
290
|
+
"runtime clamps to 1..64",
|
|
291
|
+
"read live from the peer at each start and resume, and also the ceiling: a request above the pool is clamped to it, and the run log records the clamp",
|
|
292
|
+
"16 is only the fallback for a peer that publishes no pool size",
|
|
293
|
+
"pi-subagents maxConcurrent stays the authoritative throttle; one setting governs the whole fleet",
|
|
294
|
+
"that pool is typically small (4 slots by default), so a wide fan-out runs in waves rather than all at once",
|
|
295
|
+
],
|
|
290
296
|
},
|
|
291
297
|
{
|
|
292
298
|
name: "agentRetries",
|
|
@@ -298,9 +304,13 @@ export const WORKFLOW_CAPABILITIES: readonly CapabilityEntry[] = [
|
|
|
298
304
|
{
|
|
299
305
|
name: "agentTimeoutMs",
|
|
300
306
|
classification: "workflow-tool-input",
|
|
301
|
-
signature: "agentTimeoutMs?: number =
|
|
307
|
+
signature: "agentTimeoutMs?: number = 1800000",
|
|
302
308
|
options: [],
|
|
303
|
-
constraints: [
|
|
309
|
+
constraints: [
|
|
310
|
+
"null disables the hard timeout; default 1800000ms (30min)",
|
|
311
|
+
"it bounds a hang only; an agent's real budget is the host's turn, tool and token ceiling",
|
|
312
|
+
"the clock restarts when the host reports the agent left its queue, so waiting for a slot is not charged as work",
|
|
313
|
+
],
|
|
304
314
|
},
|
|
305
315
|
{
|
|
306
316
|
name: "tokenBudget",
|
package/src/engine.ts
CHANGED
|
@@ -136,12 +136,32 @@ interface AgentLifecycleWaiter {
|
|
|
136
136
|
generation: number;
|
|
137
137
|
onUsage?: (usage: AgentUsage) => void;
|
|
138
138
|
onHistory?: (history: unknown[]) => void;
|
|
139
|
+
/** Told once when the host reports this agent left the queue and started. */
|
|
140
|
+
onRunning?: () => void;
|
|
141
|
+
startedNotified: boolean;
|
|
139
142
|
executionGeneration: number;
|
|
140
143
|
resolve: (response: ManagedSpawnResponse) => void;
|
|
141
144
|
reject: (error: unknown) => void;
|
|
142
145
|
settled: boolean;
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Tell a waiter once that its agent started.
|
|
150
|
+
*
|
|
151
|
+
* Once, because the wall clock is restarted from this point: a repeated report
|
|
152
|
+
* — a duplicate event, or a reply that raced its own lifecycle event — would
|
|
153
|
+
* keep pushing the deadline out and quietly turn the timeout off.
|
|
154
|
+
*/
|
|
155
|
+
function notifyAgentRunning(waiter: AgentLifecycleWaiter): void {
|
|
156
|
+
if (waiter.startedNotified || waiter.settled) return;
|
|
157
|
+
waiter.startedNotified = true;
|
|
158
|
+
try {
|
|
159
|
+
waiter.onRunning?.();
|
|
160
|
+
} catch {
|
|
161
|
+
/* the clock is best effort; never fail a live agent over it */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
interface AgentTerminalDelivery {
|
|
146
166
|
response: ManagedSpawnResponse;
|
|
147
167
|
usage?: AgentUsage;
|
|
@@ -432,6 +452,10 @@ export class WorkflowEngine {
|
|
|
432
452
|
if (updateCallTierIdentity(state, callNodeId, data.tier)) {
|
|
433
453
|
this.persistCallTierIdentity(state, callNodeId);
|
|
434
454
|
}
|
|
455
|
+
// A start is the moment the host admitted this agent from its queue. It is
|
|
456
|
+
// the only signal that separates waiting for a slot from doing the work, so
|
|
457
|
+
// the call's wall clock is restarted here rather than at dispatch.
|
|
458
|
+
if (eventName === "subagents:started" && waiterMatches && waiter) notifyAgentRunning(waiter);
|
|
435
459
|
// Created/started lifecycle facts carry managed identity before the RPC
|
|
436
460
|
// reply is necessarily available. They are observation-only here; terminal
|
|
437
461
|
// ownership is handled below once a completion/failure fact arrives.
|
|
@@ -1010,6 +1034,10 @@ export class WorkflowEngine {
|
|
|
1010
1034
|
: {
|
|
1011
1035
|
routingPolicy: protocol.routingPolicy,
|
|
1012
1036
|
routingPolicyFingerprint: protocol.routingPolicyFingerprint,
|
|
1037
|
+
// Read live at each start and resume, never frozen onto the run:
|
|
1038
|
+
// the pool is a host setting the user can change between them, and
|
|
1039
|
+
// a run that outlived a narrowing would dispatch into a queue.
|
|
1040
|
+
...(protocol.maxConcurrent === undefined ? {} : { hostMaxConcurrent: protocol.maxConcurrent }),
|
|
1013
1041
|
}),
|
|
1014
1042
|
concurrency: options.concurrency,
|
|
1015
1043
|
agentRetries: options.agentRetries,
|
|
@@ -1287,11 +1315,17 @@ export class WorkflowEngine {
|
|
|
1287
1315
|
executionGeneration,
|
|
1288
1316
|
onUsage: runOptions.onUsage,
|
|
1289
1317
|
onHistory: runOptions.onHistory,
|
|
1318
|
+
onRunning: runOptions.onRunning,
|
|
1319
|
+
startedNotified: false,
|
|
1290
1320
|
settled: false,
|
|
1291
1321
|
resolve,
|
|
1292
1322
|
reject,
|
|
1293
1323
|
};
|
|
1294
1324
|
state.agentWaiters.set(pendingId, waiter);
|
|
1325
|
+
// A spawn that was admitted straight away is already running, and its
|
|
1326
|
+
// `subagents:started` event fired before this waiter existed. Only a
|
|
1327
|
+
// reply that says "queued" still has a start to wait for.
|
|
1328
|
+
if (typeof response !== "string" && response.state !== "queued") notifyAgentRunning(waiter);
|
|
1295
1329
|
externalSignal.addEventListener("abort", waitAbort, { once: true });
|
|
1296
1330
|
if (externalSignal.aborted) {
|
|
1297
1331
|
waitAbort();
|
package/src/index.ts
CHANGED
|
@@ -36,11 +36,40 @@ const WorkflowToolSchema = Type.Object(
|
|
|
36
36
|
name: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
37
37
|
args: Type.Optional(Type.Any()),
|
|
38
38
|
background: Type.Optional(Type.Boolean()),
|
|
39
|
-
maxAgents: Type.Optional(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
maxAgents: Type.Optional(
|
|
40
|
+
Type.Number({
|
|
41
|
+
minimum: 1,
|
|
42
|
+
description:
|
|
43
|
+
"Cap on real agent dispatches for this run. Default 1000, which no ordinary script reaches. Omit it: a value below what the script dispatches stops the run partway with AGENT_LIMIT_EXCEEDED.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
concurrency: Type.Optional(
|
|
47
|
+
Type.Number({
|
|
48
|
+
minimum: 1,
|
|
49
|
+
description:
|
|
50
|
+
"Agents in flight at once. Defaults to the host's own subagent pool size, which is also the ceiling. Omit it: a lower number runs the same work more slowly and buys nothing, and a higher one is clamped.",
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
agentRetries: Type.Optional(
|
|
54
|
+
Type.Number({
|
|
55
|
+
minimum: 0,
|
|
56
|
+
maximum: 3,
|
|
57
|
+
description: "Retries for a recoverable agent failure. Default 0; a failed call returns null to the script.",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
tokenBudget: Type.Optional(
|
|
61
|
+
Type.Number({
|
|
62
|
+
minimum: 1,
|
|
63
|
+
description: "Soft token ceiling for the whole run. Default unlimited; exhausting a set budget aborts the run.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
agentTimeoutMs: Type.Optional(
|
|
67
|
+
Type.Number({
|
|
68
|
+
minimum: 1,
|
|
69
|
+
description:
|
|
70
|
+
"Wall clock per agent, there to bound a hang. Default 1800000 (30min), and it counts from when the agent actually starts, not from dispatch. An agent's real budget is the host's turn, tool and token ceiling. Omit it: a short value fails an entire fan-out as timeouts.",
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
44
73
|
resumeFromRunId: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
45
74
|
},
|
|
46
75
|
{ additionalProperties: false },
|
|
@@ -139,7 +168,7 @@ function formatStart(result: ScriptStartResult): string {
|
|
|
139
168
|
export function buildAdHocWorkflowScript(prompt: string): string {
|
|
140
169
|
return `export const meta = { name: "ad-hoc", description: "Plan, execute, and synthesize a bounded multi-agent task graph" };
|
|
141
170
|
const originalPrompt = ${JSON.stringify(prompt)};
|
|
142
|
-
const plan = await agent("Design a bounded execution plan for this user task. Return 1-
|
|
171
|
+
const plan = await agent("Design a bounded execution plan for this user task. Return 1-24 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible, and prefer narrow tasks over few broad ones: a task asked one question is read further into than a task asked four. User task:\\n\\n" + originalPrompt, {
|
|
143
172
|
label: "planner",
|
|
144
173
|
strength: "medium",
|
|
145
174
|
schema: {
|
|
@@ -148,14 +177,14 @@ const plan = await agent("Design a bounded execution plan for this user task. Re
|
|
|
148
177
|
tasks: {
|
|
149
178
|
type: "array",
|
|
150
179
|
minItems: 1,
|
|
151
|
-
maxItems:
|
|
180
|
+
maxItems: 24,
|
|
152
181
|
items: {
|
|
153
182
|
type: "object",
|
|
154
183
|
properties: {
|
|
155
184
|
id: { type: "string", minLength: 1, maxLength: 64 },
|
|
156
185
|
description: { type: "string", minLength: 1, maxLength: 500 },
|
|
157
186
|
prompt: { type: "string", minLength: 1, maxLength: 8000 },
|
|
158
|
-
dependsOn: { type: "array", maxItems:
|
|
187
|
+
dependsOn: { type: "array", maxItems: 24, items: { type: "string", minLength: 1, maxLength: 64 } },
|
|
159
188
|
},
|
|
160
189
|
required: ["id", "description", "prompt"],
|
|
161
190
|
additionalProperties: false,
|
|
@@ -427,9 +456,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
427
456
|
promptGuidelines: [
|
|
428
457
|
"Provide either a full `script` (raw JavaScript with the meta contract first) or a `name` of a saved/built-in workflow.",
|
|
429
458
|
"Use orchestrate([{ id, dependsOn, run }]) for named dependency graphs; use parallel(() => agent(...), ...) for a simple fan-out and pipeline(items, ...stages) for sequential per-item stages.",
|
|
459
|
+
"Size the fan-out to the work: one agent per independent piece of evidence the task actually has. Ten to thirty agents is an ordinary review or audit, and a script that splits a broad task four ways is usually four agents each doing one agent's job. The gain is coverage, not speed — how many run at once is the host's pool, not the script's choice.",
|
|
460
|
+
"Pass script/name/args/background and nothing else unless the user named a specific limit. maxAgents, concurrency, agentRetries, tokenBudget and agentTimeoutMs already default to tuned values, and each one stops or fails the run when it is set too low.",
|
|
430
461
|
"Use background: true for long runs; retrieve results with workflow_control.",
|
|
431
462
|
"Use resumeFromRunId with an edited script to replay the unchanged prefix from cache and re-run the rest live.",
|
|
432
|
-
"
|
|
463
|
+
"Inside the script, never name a model, a thinking level, or a turn limit: `strength` is the only routing a call may express.",
|
|
433
464
|
],
|
|
434
465
|
parameters: WorkflowToolSchema,
|
|
435
466
|
renderCall(args, theme) {
|
|
@@ -929,7 +960,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
929
960
|
if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
|
|
930
961
|
const effortDirective =
|
|
931
962
|
effortLevel === "ultra"
|
|
932
|
-
? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks
|
|
963
|
+
? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks. Width is where the exhaustiveness goes: keep steps at strength: "medium" and reserve "high" for a step whose error is expensive to reverse, if the run has one at all.'
|
|
933
964
|
: effortLevel === "high"
|
|
934
965
|
? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
|
|
935
966
|
: "";
|
package/src/rpc-client.ts
CHANGED
|
@@ -172,6 +172,16 @@ export interface ManagedProtocolCheck {
|
|
|
172
172
|
routingPolicy: ManagedRoutingPolicy;
|
|
173
173
|
/** Whole-catalogue identity; used only where a per-tier identity cannot be. */
|
|
174
174
|
routingPolicyFingerprint: string;
|
|
175
|
+
/**
|
|
176
|
+
* The host's background-agent pool size, when the peer published one.
|
|
177
|
+
*
|
|
178
|
+
* A workflow agent occupies one of those slots like any other background
|
|
179
|
+
* agent, so this is the width the run can actually achieve; dispatching past
|
|
180
|
+
* it only fills the host's queue. Undefined means the peer predates the
|
|
181
|
+
* field or answered through the readiness broadcast, which carries no
|
|
182
|
+
* requested fields — the caller then falls back to its own default.
|
|
183
|
+
*/
|
|
184
|
+
maxConcurrent?: number;
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
export async function checkManagedSpawnProtocol(
|
|
@@ -213,7 +223,9 @@ export async function checkManagedSpawnProtocol(
|
|
|
213
223
|
});
|
|
214
224
|
const sendPing = () => {
|
|
215
225
|
try {
|
|
216
|
-
|
|
226
|
+
// Additive reply fields are requested by name: the reply envelope is
|
|
227
|
+
// strictly parsed, so a peer may not volunteer one.
|
|
228
|
+
events.emit("subagents:rpc:ping", { requestId: id, include: ["maxConcurrent"] });
|
|
217
229
|
} catch (error: unknown) {
|
|
218
230
|
finish(undefined, error instanceof Error ? error : new Error(String(error)));
|
|
219
231
|
}
|
|
@@ -254,6 +266,7 @@ export async function checkManagedSpawnProtocol(
|
|
|
254
266
|
return {
|
|
255
267
|
routingPolicy: ping.routingPolicy.policy,
|
|
256
268
|
routingPolicyFingerprint: ping.routingPolicy.fingerprint,
|
|
269
|
+
...(ping.maxConcurrent === undefined ? {} : { maxConcurrent: ping.maxConcurrent }),
|
|
257
270
|
};
|
|
258
271
|
}
|
|
259
272
|
|
package/src/runtime.ts
CHANGED
|
@@ -155,6 +155,16 @@ export interface AgentRunOptions {
|
|
|
155
155
|
onTierResolved?: (tier: string) => void;
|
|
156
156
|
onUsage?: (usage: AgentUsage) => void;
|
|
157
157
|
onHistory?: (history: unknown[]) => void;
|
|
158
|
+
/**
|
|
159
|
+
* The agent left the host's queue and is now running.
|
|
160
|
+
*
|
|
161
|
+
* A dispatched agent does not necessarily start: it takes a background slot
|
|
162
|
+
* from pi-subagents, and a fan-out wider than that pool waits. The wall clock
|
|
163
|
+
* is restarted from this point so queue time is not charged against the work,
|
|
164
|
+
* which is what let a batch dispatched behind a busy pool fail as timeouts
|
|
165
|
+
* without any of it having run.
|
|
166
|
+
*/
|
|
167
|
+
onRunning?: () => void;
|
|
158
168
|
}
|
|
159
169
|
|
|
160
170
|
export interface AgentOptions {
|
|
@@ -260,6 +270,17 @@ export interface WorkflowRunOptions {
|
|
|
260
270
|
workflowDepth?: number;
|
|
261
271
|
cwd?: string;
|
|
262
272
|
concurrency?: number;
|
|
273
|
+
/**
|
|
274
|
+
* The host's background-agent pool size, read live from the peer at each
|
|
275
|
+
* start and resume.
|
|
276
|
+
*
|
|
277
|
+
* It is both the default width and the ceiling: a workflow agent occupies one
|
|
278
|
+
* of those slots like any other background agent, so dispatching past the pool
|
|
279
|
+
* does not run wider — it fills the host's single queue ahead of the host's own
|
|
280
|
+
* spawns. One setting therefore governs the fleet, and a run neither has to
|
|
281
|
+
* guess it nor can exceed it.
|
|
282
|
+
*/
|
|
283
|
+
hostMaxConcurrent?: number;
|
|
263
284
|
agentRetries?: number;
|
|
264
285
|
tokenBudget?: number | null;
|
|
265
286
|
signal?: AbortSignal;
|
|
@@ -337,9 +358,36 @@ export const MAX_AGENTS_PER_RUN = 1000;
|
|
|
337
358
|
export const MAX_CALLS_PER_WORKFLOW = 1_000;
|
|
338
359
|
/** Per-call item cap, matching the bounded batch contract used by reference engines. */
|
|
339
360
|
export const MAX_FANOUT_ITEMS = 4_096;
|
|
340
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Upper bound on agents in flight at once.
|
|
363
|
+
*
|
|
364
|
+
* A dispatched agent is a remote call this process waits on, not work it
|
|
365
|
+
* performs, so the ceiling guards against a runaway script rather than against
|
|
366
|
+
* machine capacity. The real throttle belongs to pi-subagents: every managed
|
|
367
|
+
* spawn takes one of its `maxConcurrent` background slots, so a host that wants
|
|
368
|
+
* a narrower fleet narrows it there, once, for every agent it runs.
|
|
369
|
+
*/
|
|
370
|
+
export const MAX_CONCURRENCY = 64;
|
|
371
|
+
/**
|
|
372
|
+
* In-flight agents when neither the run nor the caller names a number.
|
|
373
|
+
*
|
|
374
|
+
* Deliberately a constant rather than a function of `hardwareConcurrency`. Core
|
|
375
|
+
* count measures what this process can compute, and dispatching an agent
|
|
376
|
+
* computes nothing here; deriving the default from it fanned a 14-core host out
|
|
377
|
+
* 12 ways and a 4-core host 2 ways for identical, entirely remote work.
|
|
378
|
+
*/
|
|
379
|
+
export const DEFAULT_CONCURRENCY = 16;
|
|
341
380
|
export const MAX_AGENT_RETRIES = 3;
|
|
342
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Wall clock for one agent when neither the call nor the run names one.
|
|
383
|
+
*
|
|
384
|
+
* It bounds a hang and nothing else. An agent's real budget is the turn, tool
|
|
385
|
+
* and token ceiling pi-subagents applies to it, which a deep call legitimately
|
|
386
|
+
* spends over many minutes; a default an order of magnitude shorter than that
|
|
387
|
+
* budget turned every wide fan-out of real work into a batch of timeouts. Pass
|
|
388
|
+
* `null` to remove the wall clock and leave the host's budgets in sole charge.
|
|
389
|
+
*/
|
|
390
|
+
export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 1_800_000;
|
|
343
391
|
export const MAX_SCRIPT_SYNC_MS = 1_000;
|
|
344
392
|
|
|
345
393
|
// ── Determinism guardrail ────────────────────────────────────────────────────
|
|
@@ -865,13 +913,30 @@ async function withTimeout<T>(
|
|
|
865
913
|
ms: number | null,
|
|
866
914
|
label: string,
|
|
867
915
|
onTimeout?: () => void,
|
|
916
|
+
/**
|
|
917
|
+
* Receives a `restart()` that re-arms the clock from now.
|
|
918
|
+
*
|
|
919
|
+
* The clock is armed at dispatch and restarted when the agent reports that it
|
|
920
|
+
* started, rather than only armed on that report: a report that never arrives
|
|
921
|
+
* must degrade to the old behaviour, not to an agent that can hang forever.
|
|
922
|
+
*/
|
|
923
|
+
onArm?: (restart: () => void) => void,
|
|
868
924
|
): Promise<T> {
|
|
869
|
-
if (ms === null)
|
|
925
|
+
if (ms === null) {
|
|
926
|
+
onArm?.(() => {});
|
|
927
|
+
return promise;
|
|
928
|
+
}
|
|
870
929
|
|
|
871
930
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
931
|
+
// The `restart` handed out below outlives this call: the holder keeps it in a
|
|
932
|
+
// closure that the caller may still reach after the race settles. Re-arming
|
|
933
|
+
// then would leave a live timer nothing clears, firing `onTimeout` on a
|
|
934
|
+
// finished agent long after the fact, so settlement makes it inert here
|
|
935
|
+
// rather than relying on every holder to guard its own call.
|
|
936
|
+
let settled = false;
|
|
872
937
|
|
|
873
938
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
874
|
-
|
|
939
|
+
const fire = () => {
|
|
875
940
|
try {
|
|
876
941
|
onTimeout?.();
|
|
877
942
|
} catch {
|
|
@@ -884,12 +949,19 @@ async function withTimeout<T>(
|
|
|
884
949
|
{ recoverable: true },
|
|
885
950
|
),
|
|
886
951
|
);
|
|
887
|
-
}
|
|
952
|
+
};
|
|
953
|
+
timeoutId = setTimeout(fire, ms);
|
|
954
|
+
onArm?.(() => {
|
|
955
|
+
if (settled) return;
|
|
956
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
957
|
+
timeoutId = setTimeout(fire, ms);
|
|
958
|
+
});
|
|
888
959
|
});
|
|
889
960
|
|
|
890
961
|
try {
|
|
891
962
|
return await Promise.race([promise, timeoutPromise]);
|
|
892
963
|
} finally {
|
|
964
|
+
settled = true;
|
|
893
965
|
if (timeoutId) clearTimeout(timeoutId);
|
|
894
966
|
}
|
|
895
967
|
}
|
|
@@ -1185,19 +1257,38 @@ export async function runWorkflow<T = unknown>(
|
|
|
1185
1257
|
};
|
|
1186
1258
|
|
|
1187
1259
|
const agentRunner = options.agent;
|
|
1260
|
+
// The host's pool is the width that can actually be achieved, so it is the
|
|
1261
|
+
// default when it is known and the ceiling when it is not the default.
|
|
1262
|
+
// DEFAULT_CONCURRENCY only covers a peer that published no pool size.
|
|
1263
|
+
const requestedConcurrency = options.concurrency ?? options.hostMaxConcurrent ?? DEFAULT_CONCURRENCY;
|
|
1188
1264
|
const concurrency = normalizeConcurrency(
|
|
1189
|
-
options.
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
(globalThis as { navigator?: { hardwareConcurrency?: number } }).navigator?.hardwareConcurrency ?? 8,
|
|
1193
|
-
) - 2,
|
|
1265
|
+
options.hostMaxConcurrent === undefined
|
|
1266
|
+
? requestedConcurrency
|
|
1267
|
+
: Math.min(requestedConcurrency, options.hostMaxConcurrent),
|
|
1194
1268
|
);
|
|
1269
|
+
// Only a caller who asked for a specific width can be surprised by the clamp,
|
|
1270
|
+
// and the guidance now tells everything else to omit the field — so the one
|
|
1271
|
+
// caller that set it is exactly the one owed an explanation for the number it
|
|
1272
|
+
// did not get.
|
|
1273
|
+
// Floored, not raw: normalizeConcurrency floors, and reporting 12.5 -> 12 as a
|
|
1274
|
+
// clamp would blame a bound that did not bite.
|
|
1275
|
+
const askedConcurrency = options.concurrency === undefined ? undefined : Math.floor(options.concurrency);
|
|
1276
|
+
const concurrencyClampNotice =
|
|
1277
|
+
askedConcurrency !== undefined && askedConcurrency > concurrency
|
|
1278
|
+
? `concurrency ${askedConcurrency} clamped to ${concurrency}` +
|
|
1279
|
+
// Name the bound that actually bit. A pool above MAX_CONCURRENCY is not
|
|
1280
|
+
// the reason for the clamp even though it is below what was requested.
|
|
1281
|
+
(concurrency === options.hostMaxConcurrent
|
|
1282
|
+
? `: the host's subagent pool is ${concurrency}, and dispatching past it only queues`
|
|
1283
|
+
: `: the runtime ceiling is ${MAX_CONCURRENCY}`)
|
|
1284
|
+
: undefined;
|
|
1195
1285
|
|
|
1196
1286
|
const log = (message: unknown) => {
|
|
1197
1287
|
const text = String(message);
|
|
1198
1288
|
state.logs.push(text);
|
|
1199
1289
|
options.onLog?.(text);
|
|
1200
1290
|
};
|
|
1291
|
+
if (concurrencyClampNotice !== undefined) log(concurrencyClampNotice);
|
|
1201
1292
|
|
|
1202
1293
|
/**
|
|
1203
1294
|
* The strength table this run will honor, target-checked once at start.
|
|
@@ -1584,6 +1675,10 @@ export async function runWorkflow<T = unknown>(
|
|
|
1584
1675
|
);
|
|
1585
1676
|
}
|
|
1586
1677
|
|
|
1678
|
+
// Set by withTimeout below, and called by the dispatcher when the
|
|
1679
|
+
// agent leaves the host queue. Undefined until then: a start that
|
|
1680
|
+
// beats the arming simply leaves the full clock ahead of it.
|
|
1681
|
+
let restartTimeout: (() => void) | undefined;
|
|
1587
1682
|
const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
|
|
1588
1683
|
label,
|
|
1589
1684
|
sessionName: agentOptions.thread
|
|
@@ -1614,9 +1709,20 @@ export async function runWorkflow<T = unknown>(
|
|
|
1614
1709
|
onHistory: (history: unknown[]) => {
|
|
1615
1710
|
options.onAgentHistory?.({ id: deltaKey, label, phase: assignedPhase, history });
|
|
1616
1711
|
},
|
|
1712
|
+
onRunning: () => {
|
|
1713
|
+
restartTimeout?.();
|
|
1714
|
+
},
|
|
1617
1715
|
});
|
|
1618
1716
|
runPromise.catch(() => {});
|
|
1619
|
-
let result = await withTimeout(
|
|
1717
|
+
let result = await withTimeout(
|
|
1718
|
+
runPromise,
|
|
1719
|
+
timeout,
|
|
1720
|
+
label,
|
|
1721
|
+
() => agentController.abort(),
|
|
1722
|
+
(restart) => {
|
|
1723
|
+
restartTimeout = restart;
|
|
1724
|
+
},
|
|
1725
|
+
);
|
|
1620
1726
|
|
|
1621
1727
|
throwIfAborted();
|
|
1622
1728
|
if (isEmptyTextAgentResult(result, schema)) {
|