@aeondave/pi-persona 1.15.2 → 1.16.1
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/AGENTS.md +32 -6
- package/CHANGELOG.md +97 -0
- package/README.md +27 -8
- package/SECURITY.md +42 -0
- package/docs/ARCHITECTURE.md +52 -7
- package/docs/EXPERIENCE.md +21 -5
- package/docs/README.md +2 -0
- package/docs/REFERENCE.md +84 -14
- package/docs/STRATEGIES.md +58 -31
- package/docs/TELEMETRY.md +14 -0
- package/package.json +18 -11
- package/src/bus/broker/paths.ts +3 -2
- package/src/core/brief.ts +1 -1
- package/src/core/nudge.ts +3 -4
- package/src/core/pi-compat.ts +3 -3
- package/src/engine/adapter.ts +11 -6
- package/src/engine/async.ts +12 -6
- package/src/engine/inproc.ts +14 -6
- package/src/extension/engine.ts +25 -4
- package/src/extension/hooks.ts +68 -21
- package/src/extension.ts +233 -57
- package/src/orchestration/model-retry.ts +32 -61
- package/src/orchestration/params.ts +27 -1
- package/src/orchestration/roster.ts +80 -3
- package/src/orchestration/sdk.ts +150 -36
- package/src/orchestration/strategies/compete.ts +14 -8
- package/src/orchestration/strategies/council-rounds.ts +8 -1
- package/src/orchestration/strategies/critic-loop.ts +25 -11
- package/src/orchestration/strategies/judge.ts +18 -9
- package/src/orchestration/strategies/magi.ts +42 -93
- package/src/orchestration/strategies/map.ts +41 -19
- package/src/orchestration/strategies/pipeline.ts +2 -2
- package/src/orchestration/strategies/synthesize.ts +18 -11
- package/src/orchestration/types.ts +15 -0
- package/src/persona/model-participants.ts +240 -0
- package/src/persona/orchestrate.ts +44 -2
- package/src/persona/persona.ts +86 -14
- package/src/telemetry/contract.ts +1 -1
- package/src/telemetry/producer.ts +241 -8
- package/src/tools/council.ts +36 -8
- package/src/tools/delegate-tool.ts +162 -24
- package/src/tools/intercom-tool.ts +119 -32
- package/src/ui/agent-overlay.ts +15 -6
- package/src/ui/agent-tree.ts +39 -2
package/docs/REFERENCE.md
CHANGED
|
@@ -16,7 +16,7 @@ plus a file that calls `parallel` and `reduce.vote`.
|
|
|
16
16
|
|
|
17
17
|
| Primitive | Does |
|
|
18
18
|
|---|---|
|
|
19
|
-
| `agent(spec)` | run one sub-agent and return a structured `AgentResult`; `spec` may carry `name`, model, tools, skills, or `outputContract
|
|
19
|
+
| `agent(spec, { reserveChildren? }?)` | run one sub-agent and return a structured `AgentResult`; `spec` may carry `name`, model, tools, skills, or `outputContract`; optional reservations protect mandatory later legs from model recovery |
|
|
20
20
|
| `parallel(thunks, {concurrency})` | run many at once, bounded by the run limits; this also provides the basis of `map` |
|
|
21
21
|
| `reduce.aggregate(results)` | merge N results into one, used by fan-out |
|
|
22
22
|
| `reduce.vote(candidates, opts)` | tally the candidates' own votes and preserve `winner`, `tie`, `no_consensus`, `invalid_outputs`, and dissent |
|
|
@@ -35,8 +35,8 @@ hard-fail.
|
|
|
35
35
|
| Surface | Does |
|
|
36
36
|
|---|---|
|
|
37
37
|
| `delegate` tool | spawn one or more sub-agents; interactive calls are background-first, while `sync: true` blocks the turn |
|
|
38
|
-
| `council` tool |
|
|
39
|
-
| `intercom` tool | observe and control internal runs with `peek`, `result`, `wait`, `steer`, and `stop`; `list`, `inbox`, `message`, `reply`, and `send` drive the coaching bus |
|
|
38
|
+
| `council` tool | select any installed strategy over a named roster or temporary inline `members`; voting strategies preserve tally and dissent. Use when the topology adds value, not for routine work |
|
|
39
|
+
| `intercom` tool | observe and control internal runs with `peek`, `result`, `wait`, `steer`, and `stop`; `list`, `inbox`, `message`, `reply`, and `send` drive the coaching bus. `wait` snapshots without blocking by default with UI; `sync: true` opts into a bounded blocking join (headless defaults to blocking; use `sync: false` for a snapshot); queued steering does not preempt an explicit join |
|
|
40
40
|
| `timer` tool | read the fresh clock with `now`, or schedule an idle wake with `arm`, `cancel`, and `list`; alarms are one-shot and in-memory per session |
|
|
41
41
|
| `monitor` tool | run a bounded job (`mode: exit`) or event-producing program (`mode: output`) and wake the supervisor; see [`MONITORS.md`](./MONITORS.md) |
|
|
42
42
|
| `flow` tool and `/flow` | run a journaled DAG of strategies; `gate: true` creates an approval checkpoint |
|
|
@@ -49,30 +49,70 @@ hard-fail.
|
|
|
49
49
|
| `contracts/*.json` | define a hot-editable structured return requested through `outputContract` and pinned per run |
|
|
50
50
|
|
|
51
51
|
The delegate tool's optional `name` is carried into `AgentRunSpec.name` and reaches both engine
|
|
52
|
-
backends, keeping a caller-provided run label available in the live tree and runtime result.
|
|
52
|
+
backends, keeping a caller-provided run label available in the live tree and runtime result. Every
|
|
53
|
+
settled async run also emits an immediate UI banner with that name and its terminal outcome. The
|
|
54
|
+
model-facing report remains independently idle-gated; a UI notification failure cannot discard it.
|
|
55
|
+
An explicit `delegate` `async` flag overrides both `sync` and the session default: `async: true`
|
|
56
|
+
forces background execution; `async: false` forces synchronous execution.
|
|
57
|
+
|
|
58
|
+
### Pi 1.0 codemode
|
|
59
|
+
|
|
60
|
+
Pi's native `codemode` can call callable pi-persona tools through the normal nested-tool pipeline.
|
|
61
|
+
Argument validation and persona permission gates still apply to every inner call; scripts do not
|
|
62
|
+
turn a denied tool into an allowed one. Native `model-only` and `hidden` tools are not callable.
|
|
63
|
+
|
|
64
|
+
Use scripts for independent batches or for filtering large results before returning them to the
|
|
65
|
+
model, not as a wrapper that prints every result in full. `Promise.allSettled()` preserves successful
|
|
66
|
+
siblings when a call rejects; a fulfilled `bash` call still requires checking `exit_code`.
|
|
67
|
+
Only printed/returned script output reaches the model. Nested results therefore do not contribute
|
|
68
|
+
raw bytes or invisible reminders to pi-persona's context-burn nudges; an actual successful nested
|
|
69
|
+
`delegate`/`council` still resets the hand-off streak.
|
|
70
|
+
|
|
71
|
+
Pi identifies calls made through codemode's `ctx.executeTool` with `parentToolCallId`. If a nested
|
|
72
|
+
delegation report is relayed with `[BLOCKED]` or `FLAG: UNKNOWN`, only the outer model-visible result
|
|
73
|
+
gets the persistence checkpoint and its expandable card. Discarding the report creates no reminder
|
|
74
|
+
and never alters the programmatic inner result. See [the nudge contract](./ARCHITECTURE.md#supervision--the-waiting-model)
|
|
75
|
+
for the differences between synchronous and background reports.
|
|
76
|
+
|
|
77
|
+
The outer code/result card belongs to Pi, not pi-persona's delegate renderer. Keep bulky details
|
|
78
|
+
expandable (`Ctrl+O` by default; `/hotkeys` shows active bindings). `Ctrl+T` toggles thinking blocks
|
|
79
|
+
separately. A smaller script output also reduces model context; merely collapsing its card does not.
|
|
80
|
+
No replacement sandbox or renderer is required for this integration.
|
|
53
81
|
|
|
54
82
|
`outputContract` takes a contract **name**, not an inline shape — `/doctor` lists what's installed
|
|
55
|
-
(the
|
|
83
|
+
(the built-in `default` and bundled `finding` are available without custom files). Naming one that
|
|
84
|
+
isn't installed is rejected before any worker
|
|
56
85
|
spawns, checked across every task in a parallel batch: `delegate: unknown output contract(s) "x" —
|
|
57
86
|
nothing was spawned. Installed contracts: default, finding` (the list is capped at 16 names; a
|
|
58
87
|
`, …` marker is appended only when more than 16 are installed).
|
|
59
88
|
|
|
60
89
|
### Supervising running sub-agents — the `intercom` plane
|
|
61
90
|
|
|
62
|
-
The supervisor's internal communication with children
|
|
91
|
+
The supervisor's internal communication with children exposes these surfaces. Tree/F9/peek distinguish
|
|
92
|
+
queued, running, waiting for supervisor, suspected stalled and terminal runs. Waiting is derived from
|
|
93
|
+
a live pending ask on the exact child handle, never inferred from its last activity text:
|
|
63
94
|
|
|
64
|
-
|
|
|
95
|
+
| Surface | Needs | What you get |
|
|
65
96
|
|---|---|---|
|
|
66
|
-
| **Observe and control** | any persona | `peek`
|
|
97
|
+
| **Observe and control** | any persona | `peek` inspects async sub-agents; `wait` is nonblocking by default with UI (`sync: true` joins; headless defaults to joining, with `sync: false` for snapshots); `steer` sends a soft correction; `stop` requests cancellation. The `f9` overlay provides the same controls (`s`, `x`). Queued steering does not preempt an active join. Cancelling a worker does not undo existing changes |
|
|
67
98
|
| **Retrieve** | any persona | `result { to }` returns one retained run payload; `message { messageId }` returns one retained bus message. History is bounded to 256 messages and 256,000 body characters, and retrieval never starts a worker |
|
|
68
99
|
| **Message bus** | `coaching: true` | children get `contact_supervisor`; progress appears in results and `intercom inbox`, while a blocking `decision` wakes the supervisor for `intercom reply` |
|
|
69
100
|
| **Sibling peer comm** | strategy opt-in | `debate` and `pair` members always get one-way `contact_peer`; `map` and `synthesize` add it with `params: { peers: true }` |
|
|
70
101
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
102
|
+
With coaching enabled, an async child's `decision` or `interview` waits for a supervisor reply.
|
|
103
|
+
A synchronous child posts the question one-way and continues instead: the supervisor is holding
|
|
104
|
+
that turn and cannot answer a blocking ask. Progress messages are always one-way.
|
|
105
|
+
|
|
106
|
+
Delegated output is untrusted and is fenced before it reaches the supervisor. Interactive async
|
|
107
|
+
work is background-first: continue independent work, and when only children remain, end the turn for
|
|
108
|
+
automatic completion follow-ups rather than monitoring or joining all children. Use `wait` only when
|
|
109
|
+
the next step depends on results. Async runs report failures to the supervisor, and a repeated
|
|
110
|
+
identical failed delegation is stopped by the runtime ledger before another child is spawned. A run whose model's provider fails at call time (auth,
|
|
111
|
+
outage, 5xx) may retry the same model id through a compatible fallback provider when its selection
|
|
112
|
+
is unpinned; explicit delegate provider pins remain strict. Strategies and flows instead recover a
|
|
113
|
+
model/provider failure once on the current main model, including unavailable saved or pinned choices.
|
|
114
|
+
They never substitute a peer's model or search arbitrary providers. Each recovery is visible and both
|
|
115
|
+
attempts consume usage/child budget; stop, timeout, contract and infrastructure failures do not retry.
|
|
76
116
|
|
|
77
117
|
Any leg that does NOT run in `isolation: worktree` shares the real checkout, so its result gets a
|
|
78
118
|
"what changed" block appended: `--- FILES CHANGED DURING THIS LEG (shared checkout; parallel legs
|
|
@@ -235,6 +275,36 @@ Convene the council before sign-off, then apply its merged findings yourself.
|
|
|
235
275
|
council({ question: "cache this or recompute?", strategy: "debate", roster: "review", params: { bestOf: 2 } })
|
|
236
276
|
```
|
|
237
277
|
|
|
278
|
+
A one-off group can be composed without editing a team file. The external arbiter is also an
|
|
279
|
+
agent specification, independent of the panel:
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
council({
|
|
283
|
+
question: "Choose between these designs; prioritize correctness and operational cost.",
|
|
284
|
+
strategy: "judge",
|
|
285
|
+
members: [
|
|
286
|
+
{ agent: "reviewer", role: "Evaluate correctness and failure boundaries." },
|
|
287
|
+
{ agent: "reviewer", role: "Evaluate operating cost and maintainability." }
|
|
288
|
+
],
|
|
289
|
+
params: { judge: { agent: "reviewer", role: "Choose impartially from the anonymised options." } }
|
|
290
|
+
})
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`members` overrides the named roster for this invocation only. Roles/skills do not grant tools or
|
|
294
|
+
bypass capability gates. Model selection covers roster members and declared auxiliary actors such
|
|
295
|
+
as `judge` and `synthesizer`; supported agent-valued params accept inline member specs too. Model
|
|
296
|
+
precedence is inline `model`, saved role assignment, saved agent assignment, agent frontmatter,
|
|
297
|
+
then the session model (`src/persona/model-participants.ts`).
|
|
298
|
+
|
|
299
|
+
With an active persona, UI, and at least two configured models, unassigned participants can be
|
|
300
|
+
picked and saved for the active persona, even when borrowing another persona's council profile.
|
|
301
|
+
Dismissing a picker suppresses another prompt for that participant during the session, not for
|
|
302
|
+
later teams with different participants. Headless runs never prompt.
|
|
303
|
+
An unavailable choice is not overwritten: a provider or unknown-model failure may recover once
|
|
304
|
+
on the main model, with the switch reported and both attempts accounted. This does not relax
|
|
305
|
+
ordinary delegate provider pins. The MAGI persona retains its canonical core team; the Judge
|
|
306
|
+
persona retains impartial arbitration. Other personas choose whichever strategy fits the task.
|
|
307
|
+
|
|
238
308
|
**A delegation policy on any persona** — fields are generic and are never keyed to a persona name.
|
|
239
309
|
The runtime rejects incomplete briefs before a model call, supplies a missing contract, rejects
|
|
240
310
|
overlapping or missing ownership for parallel writers, and refuses a declared verifier that would
|
|
@@ -360,7 +430,7 @@ build retries. A connected broker child's status shows `⇄ <handle>`; if that c
|
|
|
360
430
|
status flips to `⇄ offline` and a fresh ask on it fails fast instead of hanging.
|
|
361
431
|
|
|
362
432
|
`/doctor` also reports the installed host's version against this extension's floor: `pi: <version>
|
|
363
|
-
(requires ≥ 0.
|
|
433
|
+
(requires ≥ 1.0.0)`, with a trailing `— BELOW FLOOR` marker once an installed version fails the
|
|
364
434
|
check (`src/core/pi-compat.ts`). `pi: unknown` means the host's own `package.json` could not be
|
|
365
435
|
resolved (a non-standard install layout) — not a failed check.
|
|
366
436
|
|
package/docs/STRATEGIES.md
CHANGED
|
@@ -17,7 +17,7 @@ a strategy is backend-agnostic and unit-testable against a stub engine.
|
|
|
17
17
|
|
|
18
18
|
| Primitive | Contract |
|
|
19
19
|
|---|---|
|
|
20
|
-
| `sdk.agent(spec)` | Run ONE sub-agent → `AgentResult`. `spec`: `{ agent, task, model?, tools?, skills?, role?, outputContract?, isolation?, mcp?, timeoutMs?, peers? }`. |
|
|
20
|
+
| `sdk.agent(spec, { reserveChildren? }?)` | Run ONE sub-agent → `AgentResult`. `spec`: `{ agent, task, name?, model?, tools?, skills?, role?, outputContract?, isolation?, mcp?, timeoutMs?, peers? }`. Optional `reserveChildren` protects mandatory later legs from spending their slots on recovery. |
|
|
21
21
|
| `sdk.parallel(thunks, {concurrency?})` | Run many at once, capped at `limits.maxConcurrency`. The basis of every fan-out (`parallel(items.map(…))`). |
|
|
22
22
|
| `sdk.reduce.aggregate(results)` | Concatenate N results into one (fan-out's merge). |
|
|
23
23
|
| `sdk.reduce.vote(candidates, opts)` | Tally the candidates' OWN votes → a `ReducerResult` (`voting.ts`). |
|
|
@@ -27,7 +27,8 @@ a strategy is backend-agnostic and unit-testable against a stub engine.
|
|
|
27
27
|
| series & loops | Plain `await` / `for` — a strategy is TypeScript, so `pipeline` and `critic-loop` are just native control flow. |
|
|
28
28
|
|
|
29
29
|
Run limits (`RUN_LIMITS`) are enforced inside `makeSDK` regardless of how a strategy calls `agent()`:
|
|
30
|
-
`maxChildren`, `maxConcurrency`, `budgetTokens`, `timeoutMs` (idle window).
|
|
30
|
+
`maxChildren`, `maxConcurrency`, `budgetTokens`, `timeoutMs` (idle window). Model recovery attempts
|
|
31
|
+
consume child slots and usage too; they are not free legs hidden outside these limits. Nesting depth is not a
|
|
31
32
|
numeric knob on this list — it is structurally 1: a spawned child's whole pi-persona extension
|
|
32
33
|
disables itself under `PI_PERSONA_DISABLE=1`, so it has no `delegate`/`council`/`orchestrate`/`flow`
|
|
33
34
|
tool to call however a strategy or persona tries to nest it (the in-process engine additionally
|
|
@@ -39,6 +40,13 @@ indefinitely; the idle watchdog catches silence. Safety comes from these runtime
|
|
|
39
40
|
structural depth-1 guarantee, not from sandboxing the strategy (see the I2 invariant in
|
|
40
41
|
[ARCHITECTURE.md](ARCHITECTURE.md)).
|
|
41
42
|
|
|
43
|
+
Optional main-model recovery leaves admission room for queued logical legs and parallel thunks.
|
|
44
|
+
`agent(spec, { reserveChildren })` additionally reserves slots for a mandatory later phase;
|
|
45
|
+
`judge`, `compete`, and `synthesize` protect their final arbiter/merge this way. `map` reserves the
|
|
46
|
+
worker/verifier waves at splitting and the remaining verifier wave during workers; `pipeline`,
|
|
47
|
+
`critic-loop`, and `council-rounds` reserve their remaining required steps/rounds. Recovery must not
|
|
48
|
+
turn a usable partial panel into a run-fatal child-budget overflow.
|
|
49
|
+
|
|
42
50
|
Every `agent()` call in one SDK instance shares a concurrency semaphore, including direct
|
|
43
51
|
`Promise.all` calls and overlapping `parallel()` batches. A requested batch concurrency may lower
|
|
44
52
|
the ceiling, never raise it. Cancellation removes queued legs before they reach an engine.
|
|
@@ -55,6 +63,13 @@ through `rosterSpec` to honour the specialisation — **`critic-loop` is the one
|
|
|
55
63
|
`generator`/`critic` to full specs so a role-carrying roster works, while still accepting a bare-name
|
|
56
64
|
`params.generator`/`critic` override.
|
|
57
65
|
|
|
66
|
+
The `council` tool also accepts temporary `members`, with the same member shape. These override the
|
|
67
|
+
named roster for that invocation only: no team or persona file is created or changed. An empty or
|
|
68
|
+
invalid list is an error, not a request to silently fall back to MAGI. External actors such as
|
|
69
|
+
`params.judge`, `params.synthesizer`, `params.generator`, `params.critic`, and `map`'s `params.verify`
|
|
70
|
+
accept a bare agent name
|
|
71
|
+
or an inline member specification, so the arbiter can have its own role and model too.
|
|
72
|
+
|
|
58
73
|
Same-agent members are disambiguated in the live `f9` tree by a role hint (`reviewer · SECURITY`) via
|
|
59
74
|
`rosterNodeKeys`/`roleHint` (`roster.ts`) + the SDK's per-run key (`sdk.ts`). If you touch either, keep
|
|
60
75
|
the seeding loop and the SDK key derivation in lockstep, and inject any per-run protocol via the TASK
|
|
@@ -66,7 +81,7 @@ text, never `role` (or the derived tree key drifts from the seeded one).
|
|
|
66
81
|
|---|---|---|---|
|
|
67
82
|
| `fanout` | Every roster agent on the same task in parallel, then `aggregate`. | — | roster-role |
|
|
68
83
|
| `pipeline` | Roster in SEQUENCE, each builds on the prior output; answer = last step. | — | roster-role |
|
|
69
|
-
| `map` | A splitter breaks the task into a runtime list; a worker runs once per item in parallel, then `aggregate`. | `maxItems` (default AND ceiling: maxChildren − 1, the splitter takes a slot; a larger value is clamped and the drop is noted in the output), `peers` (false), `ownership` ("off" — "off"/"declare"/"enforce": ignore, record, or gate on the splitter's per-item `writeSet`; always exposed as `structured.items`, a per-item status ledger), `verify` ("" — agent that re-checks each COMPLETED item read-only; one extra child per completed item, empty = off) | roster-role, opt-in peers |
|
|
84
|
+
| `map` | A splitter breaks the task into a runtime list; a worker runs once per item in parallel, then `aggregate`. | `maxItems` (default AND ceiling: maxChildren − 1 without verification, halved and rounded down with `verify`, minimum 1; the splitter takes a slot; a larger value is clamped and the drop is noted in the output), `peers` (false), `ownership` ("off" — "off"/"declare"/"enforce": ignore, record, or gate on the splitter's per-item `writeSet`; always exposed as `structured.items`, a per-item status ledger), `verify` ("" — agent that re-checks each COMPLETED item read-only; one extra child per completed item, empty = off) | roster-role, opt-in peers |
|
|
70
85
|
| `critic-loop` | Generator proposes, critic attacks; `reject`/`revise` triggers another draft, and only explicit `approve` succeeds. Exhaustion fails closed with the last reviewed draft + unresolved critique (never an unreviewed tail revision). | `generator` (roster[0]), `critic` (roster[1]), `rounds` (positive integer, 3) | roster-role, `outputContract` |
|
|
71
86
|
| `magi` | Parallel INDEPENDENT votes from distinct-persona cores → majority/unanimity, tally + minority report; one anonymised reflection round by default. | `aggregate` ("majority"), `reflect` (true) | vote reducer |
|
|
72
87
|
| `council-rounds` | Multi-round `magi`, best-of-X: the whole roster re-deliberates carrying the debate forward until a supermajority, else best-by-confidence on the last round. | `rounds` (3), `bestOf` (majority), `aggregate` ("majority") | vote reducer |
|
|
@@ -82,10 +97,12 @@ text, never `role` (or the derived tree key drifts from the seeded one).
|
|
|
82
97
|
single read-only review pass per completed unit of work, not swarm autonomy for its own sake. It
|
|
83
98
|
runs one extra child per COMPLETED item (a failed item has nothing to re-check), through the same
|
|
84
99
|
`sdk.parallel` wave as the workers, so `maxChildren`/`maxConcurrency`/`budgetTokens` still bound it
|
|
85
|
-
—
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
— `map` automatically reduces the item ceiling to leave one verifier slot per item. With the default
|
|
101
|
+
`maxChildren: 64`, that allows 31 items (1 splitter + 31 workers + 31 verifiers); a lower `maxItems`
|
|
102
|
+
can leave more room for optional model recovery. It pays for a batch where a wrong answer is
|
|
103
|
+
expensive and hard to eyeball after the fact (security-sensitive edits, long-tail correctness);
|
|
104
|
+
it costs more than it is worth for a low-risk sweep a supervisor can spot-check itself.
|
|
105
|
+
A verifier's stance is read via the SAME
|
|
89
106
|
`outputContract: "default"` machinery `critic-loop` uses for its critic (`structured.stance`):
|
|
90
107
|
`"approve"` passes, anything else — an explicit reject/revise, a missing stance, or a verifier leg
|
|
91
108
|
that itself failed to run — flips that item's `structured.items` ledger entry to `status: "failed"`
|
|
@@ -134,22 +151,18 @@ council. They are enforced structurally, not by convention.
|
|
|
134
151
|
failures stay excluded) — so `magi`/`council-rounds`/`debate` degrade to the strongest single
|
|
135
152
|
response rather than returning `ok: false`. The "N invalid excluded" footer counts only
|
|
136
153
|
genuinely-dropped candidates, not the surfaced prose.
|
|
137
|
-
- **A broken MODEL
|
|
138
|
-
`failureKind: "provider"`
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
the
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
observed token spend: recovery attempts run serially so each completion can close the token gate
|
|
150
|
-
before another billed leg starts, and reflection starts only when the remaining observable budget
|
|
151
|
-
can fund the whole panel. A retry or reflection it cannot afford is stated rather than silently
|
|
152
|
-
skipped.
|
|
154
|
+
- **A broken MODEL recovers only on the main model.** All strategies use the same SDK rule:
|
|
155
|
+
`failureKind: "provider"` or `"unknown-model"` permits ONE recovery attempt on the supervisor's
|
|
156
|
+
current model. This is explicitly authorized even when the initial member model was pinned or
|
|
157
|
+
saved earlier. It never borrows a peer's model or searches arbitrary providers, and skips recovery
|
|
158
|
+
when the main model is absent, already failed on that leg, or cannot fit the remaining child/token
|
|
159
|
+
budget. Other failures (`abort`, `timeout`, `contract`, agent/infrastructure errors) are terminal;
|
|
160
|
+
a stopped child never restarts. Strategy/flow engine construction disables the ordinary provider
|
|
161
|
+
reroute decorator so this is the first and only model switch. Every attempt contributes its usage
|
|
162
|
+
and child count; recovery is reported through `modelRecovery` and a visible warning. MAGI discloses
|
|
163
|
+
recovered cores in its ruling and keeps the recovered model during reflection rather than sending
|
|
164
|
+
a core back to its broken assignment. Recovery is a degraded-mode safeguard, not a preference:
|
|
165
|
+
sharing the main model can reduce reasoner diversity.
|
|
153
166
|
- **The contract instructs as well as validates.** An engine that receives `outputContract` appends
|
|
154
167
|
the format block (`contractInstructions`, derived from the same pinned def it validates against)
|
|
155
168
|
to the member's task — a bare generic agent votes as reliably as one whose `.md` spells the JSON
|
|
@@ -167,8 +180,11 @@ nothing. Vote keys are normalised (`json-first`, `JSON_First`, `json first` →
|
|
|
167
180
|
## Param schema (declaration · validation · discovery)
|
|
168
181
|
|
|
169
182
|
Each strategy declares its params as `params?: Record<string, StrategyParam>` where `StrategyParam =
|
|
170
|
-
{ type: "string" | "number" | "boolean"; default?; doc }`.
|
|
171
|
-
|
|
183
|
+
{ type: "string" | "number" | "boolean" | "agent"; default?; rosterIndex?; inheritRoster?; doc }`.
|
|
184
|
+
Agent-valued defaults name real agents; `rosterIndex` identifies an omitted selector's roster default,
|
|
185
|
+
and `inheritRoster` preserves a named selector's roster specialization where that strategy uses it.
|
|
186
|
+
Picker preflight reads these declarations instead of guessing participants from prose. `knownParams(name)`
|
|
187
|
+
(`strategy.ts`) exposes them. Two consumers:
|
|
172
188
|
|
|
173
189
|
- The **`council` tool** warns (via `ui.notify`, never hard-fails — I2 lenient) when a call passes a
|
|
174
190
|
key the active strategy doesn't declare, e.g. `ignoring unknown param "reflct" for magi (known:
|
|
@@ -184,19 +200,30 @@ The schema is for discovery and typo-catching, not enforcement: a strategy still
|
|
|
184
200
|
Options reach a strategy's `input.params` from four surfaces, all overridable by the supervisor
|
|
185
201
|
per-call:
|
|
186
202
|
|
|
187
|
-
1. **Author default (static)** — a persona's `council: { strategy
|
|
203
|
+
1. **Author default (static)** — a persona's `council: { strategy?, roster?, members?, params? }` (tool-driven) or
|
|
188
204
|
mandatory `orchestration: { mode, strategy, roster, params }` both carry a `params` map
|
|
189
205
|
(`persona.ts` → `orchestrate.ts`). E.g. `council: { strategy: magi, params: { reflect: false } }`.
|
|
190
206
|
2. **Borrowed persona profile (dynamic)** — `council({ persona: "magi", question: … })` resolves that
|
|
191
207
|
installed persona's already-expanded `council:` block. The caller remains active and retains its
|
|
192
|
-
prompt, model, tools, and capability gates; only strategy/roster/params are borrowed. An unknown
|
|
208
|
+
prompt, model, tools, and capability gates; only strategy/roster/members/params are borrowed. An unknown
|
|
193
209
|
persona or one without a usable council is a hard error, never a silent MAGI fallback.
|
|
194
|
-
3. **Supervisor override (dynamic)** — the `council` tool accepts a per-call `strategy`,
|
|
195
|
-
`
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
3. **Supervisor override (dynamic)** — the `council` tool accepts a per-call `strategy`, named
|
|
211
|
+
`roster`, temporary `members`, and `params`. Params merge over the selected (or active) profile when the strategy stays the same.
|
|
212
|
+
Switching strategy starts from that strategy's own defaults; only explicitly supplied per-call
|
|
213
|
+
params carry across the switch. For example, `{ aggregate: "unanimity" }` overrides the active
|
|
214
|
+
strategy for one invocation, while `council({ strategy: "debate", … })` does not inherit unrelated
|
|
215
|
+
profile params. Explicit unknown keys still produce a warning (leniently ignored), never a hard
|
|
216
|
+
failure.
|
|
198
217
|
4. **Preset (reusable bundle)** — `council: { preset: <name> }` expands `presets/<name>.preset.json`
|
|
199
218
|
into `{ strategy, roster, params }`; authored fields win, params merge (`expandCouncilPreset`).
|
|
219
|
+
Strategy/roster names are trimmed, and only those three preset fields are copied. Inline `members`
|
|
220
|
+
belong in the persona's validated council declaration or the tool call, not in preset JSON.
|
|
221
|
+
|
|
222
|
+
An authored members-only declaration such as `council: { members: [reviewer, research] }` is valid
|
|
223
|
+
and defaults to `magi`. Its params remain in effect when a call explicitly selects that same default
|
|
224
|
+
strategy. Empty or malformed authored `members` are a configuration error, not an absent roster:
|
|
225
|
+
the parser retains the diagnostic and council resolution fails instead of choosing a fallback panel.
|
|
226
|
+
Correct or remove the field before calling that persona's council, even with per-call overrides.
|
|
200
227
|
|
|
201
228
|
The mandatory `orchestration:` path fires pre-turn on the raw user text, so it takes no dynamic
|
|
202
229
|
per-call params (author params are threaded intact) — that is the difference between the two modes:
|
package/docs/TELEMETRY.md
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
# Telemetry wire contract
|
|
2
2
|
|
|
3
|
+
Agent `waiting` is an explicit projection of a pending supervisor ask, read through the live
|
|
4
|
+
engine steer handle. Ask delivery and settlement (reply, timeout or cancellation) refresh the
|
|
5
|
+
projection; overlapping asks keep it waiting until the last one settles. Queued and terminal
|
|
6
|
+
statuses retain precedence. The producer deduplicates unchanged projections and never infers
|
|
7
|
+
waiting from tool arguments, output text or a quiet clock.
|
|
8
|
+
|
|
3
9
|
Telemetry is a vendor-neutral v2 envelope. `producerId` and `producerVersion` identify the emitting plugin; `version` identifies the wire schema. `seq` is monotonic only within `(producerId, sessionId)`, and `id` is deduplicated within that same scope.
|
|
4
10
|
|
|
5
11
|
The pi-persona producer writes semantic projections under `telemetry/v2/<workspace>/<producerId>/`. `<workspace>` always identifies the Pi process's actual cwd; joining another Exocom scope does not rewrite telemetry identity. It never persists task/prompt text, tool arguments/activity, model output, paths, secrets, or message bodies. Known pi-persona event payloads are allowlisted; an unreviewed producer event retains only its envelope/type (payload `{}`) at the producer and consumer boundary. Future producers must add a reviewed allowlist/schema before publishing data intended for a consumer.
|
|
6
12
|
|
|
7
13
|
JSONL compaction is atomic and hysteretic. It preserves the initial `instance.started` anchor and complete records; the configured byte limit is therefore a soft bound for an anchor plus one complete event. The `.previous` backup is recovered after an interrupted swap.
|
|
8
14
|
|
|
15
|
+
Producer shutdown flushes its terminal event before releasing its writer lease. It does not join the shared background retention sweep, so unrelated stale sessions cannot delay this shutdown or reload. `flushRetention()` remains the explicit await for maintenance completion; cleanup errors still reach the non-fatal diagnostic sink.
|
|
16
|
+
|
|
17
|
+
The pi-persona producer also applies a default 30-day age retention to its own valid v2 session groups; `retentionMs: 0` disables cleanup only, not writer leases. Cleanup is asynchronous after successful initialization and can be awaited with the producer's retention test seam. It strictly considers regular single-link logs plus recognized `.previous`/compaction scratch files beneath valid workspace IDs and this producer directory. Legacy data, foreign producers, malformed names, links, and unknown files are retained. Orphan backups/scratch and dead-marker-only groups are considered too; empty owned lease/producer/workspace directories are removed nonrecursively. The cutoff is exclusive and cleanup runs on activation, not on a wall-clock deletion schedule.
|
|
18
|
+
|
|
19
|
+
Retention and writers coordinate cooperatively on the same host using unique empty `0600` marker files in `<session>.jsonl.leases`. Writers claim before recovery/sequence reads and keep the claim through terminal publication and flush. A pruner claims before checking writers and revalidates the whole old session group before unlinking. Marker names are immutable and unique to avoid stale-lock ABA. PID liveness is conservative: only `ESRCH` proves death; current, live, permission-denied, and unknown states protect data. This protocol cannot protect against historic writers that predate leases, nor non-cooperating processes; it is not an OS-level lock. A pre-upgrade process has no marker: its log is protected only while normal writes/heartbeats keep its mtime newer than the cutoff. That incidental mtime grace is not active-writer protection; let old sessions settle before reloading/upgrading when possible.
|
|
20
|
+
|
|
21
|
+
Admission fails before recovery, sequence reads or emission when a live prune claim, unknown or unsafe lease marker, linked namespace/artifact, or filesystem error prevents a safe writer claim. The host warns and continues without telemetry for that activation. A failed attempt removes only its own marker and empty directories it created, nonrecursively; pre-existing data and leases remain intact. Cleanup and writer errors reach an always-enabled, non-fatal `onError` sink: a sanitized warning in interactive/RPC sessions, or stderr headless. A failing diagnostic renderer cannot interrupt the session. Retention deletes the sequence source: resuming an expired log starts a new retained stream at sequence 1, so it does not promise continuity with previously exported expired history.
|
|
22
|
+
|
|
9
23
|
The flow consumer is a compatibility reader for v1 logs, which sit under `persona/flow` on an install the storage-root consolidation has migrated and under `pi-persona/flow` on one that predates it; the consumer reads both roots, because no producer writes v1 any more and dropping either name would silently lose history. It re-implements the parser rather than importing this one, and that duplication is deliberate: a consumer that ran the producer's parser could not validate against a buggy or hostile producer, and a vendor-neutral contract cannot make every future producer depend on pi-persona. What keeps the two honest is a conformance corpus — one byte-identical block of wire cases (accept, reject, and the exact projected payload) carried by `test/unit/telemetry/parity.test.ts` here and `test/parity.test.ts` there. The flow suite enforces that identity, comparing its block against the one in a sibling pi-persona checkout (or `PI_PERSONA_REPO`) and naming the first line that differs; it lives there and not here because flow may depend on pi-persona and never the reverse, and it skips — never fails — when no such checkout resolves, so flow stays testable standalone. A divergence therefore surfaces as a named line rather than as a silently dropped event. `TELEMETRY_PRODUCER_VERSION` is asserted against `package.json` in the same test: a release bumps both or fails. Any future shared package must preserve v1 read compatibility, v2 producer/session scoping, and the corpus.
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeondave/pi-persona",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Supervised multi-agent orchestration for Pi — async workers, live steering, cross-session collaboration, and switchable personas.",
|
|
7
|
+
"author": "AeonDave",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
9
10
|
"url": "git+https://github.com/AeonDave/pi-persona.git"
|
|
@@ -18,12 +19,16 @@
|
|
|
18
19
|
"keywords": [
|
|
19
20
|
"pi-package",
|
|
20
21
|
"pi-extension",
|
|
22
|
+
"pi-coding-agent",
|
|
21
23
|
"pi",
|
|
22
|
-
"persona",
|
|
23
24
|
"subagents",
|
|
24
25
|
"orchestration",
|
|
25
26
|
"multi-agent",
|
|
26
|
-
"supervisor"
|
|
27
|
+
"supervisor",
|
|
28
|
+
"async-agents",
|
|
29
|
+
"live-steering",
|
|
30
|
+
"cross-session",
|
|
31
|
+
"persona"
|
|
27
32
|
],
|
|
28
33
|
"license": "MIT",
|
|
29
34
|
"files": [
|
|
@@ -50,6 +55,7 @@
|
|
|
50
55
|
"README.md",
|
|
51
56
|
"AGENTS.md",
|
|
52
57
|
"CHANGELOG.md",
|
|
58
|
+
"SECURITY.md",
|
|
53
59
|
"LICENSE"
|
|
54
60
|
],
|
|
55
61
|
"scripts": {
|
|
@@ -84,20 +90,21 @@
|
|
|
84
90
|
}
|
|
85
91
|
},
|
|
86
92
|
"engines": {
|
|
87
|
-
"node": ">=22"
|
|
93
|
+
"node": ">=22.19.0"
|
|
88
94
|
},
|
|
89
95
|
"devDependencies": {
|
|
90
|
-
"@earendil-works/pi-agent-core": "0.
|
|
91
|
-
"@earendil-works/pi-ai": "0.
|
|
92
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
93
|
-
"@earendil-works/pi-tui": "0.
|
|
96
|
+
"@earendil-works/pi-agent-core": "1.0.0",
|
|
97
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
98
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
99
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
94
100
|
"@types/node": "^22.20.4",
|
|
101
|
+
"fast-check": "^4.10.2",
|
|
95
102
|
"tsx": "^4.23.13",
|
|
96
|
-
"
|
|
97
|
-
"
|
|
103
|
+
"typebox": "1.3.34",
|
|
104
|
+
"typescript": "^7.0.2"
|
|
98
105
|
},
|
|
99
106
|
"pi": {
|
|
100
|
-
"image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.
|
|
107
|
+
"image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.16.1/assets/banner.png",
|
|
101
108
|
"video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
|
|
102
109
|
"extensions": [
|
|
103
110
|
"./src/extension.ts"
|
package/src/bus/broker/paths.ts
CHANGED
|
@@ -25,7 +25,8 @@ export function brokerEndpoint(sessionId: string, home: string = homedir(), plat
|
|
|
25
25
|
// Session ids are uuidv7 (36 chars), so an ordinary-but-longish $HOME already pushes the
|
|
26
26
|
// readable name past sun_path; a hashed one is short and still derived purely from the
|
|
27
27
|
// session id, so host and client compute the same endpoint independently. The hashed form
|
|
28
|
-
// costs `$HOME` +
|
|
28
|
+
// costs `$HOME` + 38 bytes, so a $HOME past 65 bytes stays out of reach either way —
|
|
29
29
|
// nothing under the user's own dir would fit.
|
|
30
|
-
|
|
30
|
+
// base64url packs 72 SHA-256 bits into the same 12 path bytes as the old hex suffix.
|
|
31
|
+
return join(dir, `b-${createHash("sha256").update(sid).digest("base64url").slice(0, 12)}.sock`);
|
|
31
32
|
}
|
package/src/core/brief.ts
CHANGED
|
@@ -148,7 +148,7 @@ export function buildDelegationBrief(input: BriefInput): string | undefined {
|
|
|
148
148
|
|
|
149
149
|
const example = input.agents.some((a) => a.name === "operator") ? "operator" : (listed[0]?.name ?? "operator");
|
|
150
150
|
const delivery = input.asyncDefault
|
|
151
|
-
? "they run in the background and each result returns to you automatically as a follow-up — don't watch or poll a healthy leg; you're woken if
|
|
151
|
+
? "they run in the background and each result returns to you automatically as a follow-up — do useful independent work while children run; when only children remain, end your turn and let completion wake you. Do not monitor, poll, or automatically wait for all children (don't watch or poll a healthy leg). Join intentionally only when a result is needed before your next step; you're also woken if a child stalls or messages you, plus an occasional check-in to catch drift"
|
|
152
152
|
: "the call returns when they finish";
|
|
153
153
|
const callFields = [`agent: "${example}"`, `task: "<self-contained bounded task + success signal>"`];
|
|
154
154
|
if (input.requireBrief) {
|
package/src/core/nudge.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
/** Tools that mean "the operator handed work off" — they end (reset) a by-hand run. */
|
|
29
|
-
const
|
|
29
|
+
export const HANDOFF_TOOL_NAMES: ReadonlySet<string> = new Set(["delegate", "council"]);
|
|
30
30
|
/** Supervision glue: collecting a delegated result (`intercom wait`/`result`) or arming a
|
|
31
31
|
* `timer` is neither hands-on work nor a hand-off. Their payloads are large by nature, so
|
|
32
32
|
* counting them would punish the exact behavior the nudge exists to encourage. They neither
|
|
@@ -138,7 +138,7 @@ export class DelegationNudge {
|
|
|
138
138
|
*/
|
|
139
139
|
observe(toolName: string, size: number, handoffSucceeded = true, failureText?: string): string | undefined {
|
|
140
140
|
if (GLUE_TOOLS.has(toolName)) return undefined;
|
|
141
|
-
if (
|
|
141
|
+
if (HANDOFF_TOOL_NAMES.has(toolName)) {
|
|
142
142
|
if (!handoffSucceeded) {
|
|
143
143
|
const normalizedSize = Math.max(0, Math.floor(size));
|
|
144
144
|
const identity = failureText === undefined ? `size:${normalizedSize}` : fingerprintFailure(failureText);
|
|
@@ -204,7 +204,6 @@ export class DelegationNudge {
|
|
|
204
204
|
*/
|
|
205
205
|
|
|
206
206
|
/** Tools whose result is a delegated child's report (where a surrender can surface). */
|
|
207
|
-
const REPORT_TOOLS = new Set(["delegate", "council"]);
|
|
208
207
|
|
|
209
208
|
/** Explicit surrender/blocked markers the operator + CTF protocols emit (operator.md). */
|
|
210
209
|
const SURRENDER_MARKERS: readonly RegExp[] = [/\[BLOCKED\b/i, /\bFLAG:\s*UNKNOWN\b/i];
|
|
@@ -222,7 +221,7 @@ export class PersistenceNudge {
|
|
|
222
221
|
* The SYNC path (a `delegate`/`council` tool_result the supervisor sees inline).
|
|
223
222
|
*/
|
|
224
223
|
observe(toolName: string, text: string): string | undefined {
|
|
225
|
-
if (!
|
|
224
|
+
if (!HANDOFF_TOOL_NAMES.has(toolName)) return undefined;
|
|
226
225
|
return this.scan(text);
|
|
227
226
|
}
|
|
228
227
|
|
package/src/core/pi-compat.ts
CHANGED
|
@@ -3,10 +3,10 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
-
/** Oldest Pi
|
|
7
|
-
*
|
|
6
|
+
/** Oldest supported Pi host. The release is qualified against Pi 1.0's SDK and tool lifecycle;
|
|
7
|
+
* pre-1.0 hosts are unsupported. Keep in sync with README.md;
|
|
8
8
|
* npm host peers use "*" because Pi supplies them through its extension loader. */
|
|
9
|
-
export const MIN_PI_VERSION = "0.
|
|
9
|
+
export const MIN_PI_VERSION = "1.0.0";
|
|
10
10
|
|
|
11
11
|
function parse(v: string): [number, number, number] | undefined {
|
|
12
12
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
package/src/engine/adapter.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { type ContractDef, contractInstructions, parseAndValidate, pinContract,
|
|
|
12
12
|
import { sanitizeDisplayLabel } from "../core/display-label.ts";
|
|
13
13
|
import { assignedIdentityPrompt } from "../core/naming.ts";
|
|
14
14
|
import { roleHint } from "../orchestration/roster.ts";
|
|
15
|
-
import { type AgentRunSpec, isPositiveFiniteMs, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
15
|
+
import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
16
16
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
17
17
|
import { type ChildEngineOptions, type ChildRunSpec, runChildAgent } from "./child.ts";
|
|
18
18
|
import { looksLikeProviderError } from "./errors.ts";
|
|
@@ -51,8 +51,10 @@ export interface EngineAdapterDeps {
|
|
|
51
51
|
/** Installed output-contract names (built-in `default` + files) for the self-correcting hint. */
|
|
52
52
|
listContracts?: () => string[];
|
|
53
53
|
/** Per-agent model override (e.g. a persona's configured ensemble models).
|
|
54
|
-
* Precedence: explicit spec.model > modelFor(agent) > the agent's own default.
|
|
55
|
-
|
|
54
|
+
* Precedence: explicit spec.model > modelFor(agent, role) > the agent's own default. `role` is
|
|
55
|
+
* the on-the-fly role this run carries, when it has one, so a member's own assignment key
|
|
56
|
+
* (`agent#hash`) resolves ahead of the bare agent's. */
|
|
57
|
+
modelFor?: (agent: string, role?: string) => string | undefined;
|
|
56
58
|
/** Explicit thinking level appended to the child model (`model:level`) so it can't
|
|
57
59
|
* fall into a model's default "adaptive" mode, which some models reject. */
|
|
58
60
|
childThinking?: string;
|
|
@@ -107,7 +109,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
107
109
|
onProgress?: (p: { output: string; tokens?: number; activity?: string; toolEvent?: ToolEvent }) => void,
|
|
108
110
|
callSignal?: AbortSignal,
|
|
109
111
|
/** Called once with a steer handle when the broker is on (in-process parity). */
|
|
110
|
-
onSteerable?: (steer:
|
|
112
|
+
onSteerable?: (steer: SteerFn) => void,
|
|
111
113
|
): Promise<AgentResult> {
|
|
112
114
|
const cfg = deps.resolveAgent(spec.agent);
|
|
113
115
|
if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
|
|
@@ -125,7 +127,7 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
125
127
|
: spec.task;
|
|
126
128
|
const task = contractDef ? `${withSkills}\n\n${contractInstructions(contractDef)}` : withSkills;
|
|
127
129
|
const childSpec: ChildRunSpec = { task };
|
|
128
|
-
let model = spec.model ?? deps.modelFor?.(spec.agent) ?? cfg.model;
|
|
130
|
+
let model = spec.model ?? deps.modelFor?.(spec.agent, spec.role) ?? cfg.model;
|
|
129
131
|
// Append an explicit thinking level (model:level) unless one is already present —
|
|
130
132
|
// a child without it defaults to "adaptive", which some models reject.
|
|
131
133
|
if (model && deps.childThinking && !model.includes(":")) model = `${model}:${deps.childThinking}`;
|
|
@@ -178,7 +180,10 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
178
180
|
childOptions.isBlocked = () => deps.broker!.hasPendingAskFrom(handle!);
|
|
179
181
|
const broker = deps.broker;
|
|
180
182
|
const h = handle;
|
|
181
|
-
|
|
183
|
+
const steer: SteerFn = Object.assign((text: string) => broker.steerFrame(h, text), {
|
|
184
|
+
isWaitingForSupervisor: () => broker.hasPendingAskFrom(h),
|
|
185
|
+
});
|
|
186
|
+
onSteerable?.(steer);
|
|
182
187
|
}
|
|
183
188
|
|
|
184
189
|
let child: Awaited<ReturnType<typeof runChildAgent>>;
|
package/src/engine/async.ts
CHANGED
|
@@ -320,7 +320,7 @@ export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
|
|
|
320
320
|
|
|
321
321
|
/** One identity everywhere a tracked run is rendered: the launcher's codename when present,
|
|
322
322
|
* otherwise the agent type, with the already-shortened model appended once. */
|
|
323
|
-
function runDisplayName(run: AsyncRun): string {
|
|
323
|
+
export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">): string {
|
|
324
324
|
const model = run.model === undefined ? undefined : sanitizeDisplayLabel(run.model, "model", 24);
|
|
325
325
|
if (model === undefined) return sanitizeDisplayLabel(run.label ?? run.agent);
|
|
326
326
|
const suffix = ` · ${model}`;
|
|
@@ -445,7 +445,7 @@ export function clipRunOutput(text: string, id: string, maxChars: number, label
|
|
|
445
445
|
return `${text.slice(0, headChars)}${marker}${tailChars > 0 ? text.slice(-tailChars) : ""}`;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
-
export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number }): string {
|
|
448
|
+
export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
|
|
449
449
|
if (runs.length === 0) return "No async runs.";
|
|
450
450
|
const running = runs.filter((r) => r.status === "running").length;
|
|
451
451
|
const now = opts?.now;
|
|
@@ -460,7 +460,8 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
460
460
|
const head = `[${r.id}] ${name} — ${r.status}`;
|
|
461
461
|
if (r.status === "running") {
|
|
462
462
|
let line = `${head} (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
463
|
-
if (
|
|
463
|
+
if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
|
|
464
|
+
else if (now !== undefined && stallMs !== undefined && stallMs > 0 && r.lastAdvanceAt !== undefined) {
|
|
464
465
|
const stalledFor = now - r.lastAdvanceAt;
|
|
465
466
|
if (stalledFor >= stallMs) line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
|
|
466
467
|
}
|
|
@@ -493,12 +494,17 @@ export class PeekWatcher {
|
|
|
493
494
|
private readonly reportedAt = new Map<string, number>();
|
|
494
495
|
|
|
495
496
|
/** The running legs that have NEWLY crossed the stall window since we last reported them. */
|
|
496
|
-
poll(runs: AsyncRun[], now: number, stallMs: number): AsyncRun[] {
|
|
497
|
+
poll(runs: AsyncRun[], now: number, stallMs: number, waitingForSupervisor: ReadonlySet<string> = new Set()): AsyncRun[] {
|
|
497
498
|
const live = new Set<string>();
|
|
498
499
|
const newlyStuck: AsyncRun[] = [];
|
|
499
500
|
for (const r of runs) {
|
|
500
|
-
if (r.status !== "running"
|
|
501
|
+
if (r.status !== "running") continue;
|
|
501
502
|
live.add(r.id);
|
|
503
|
+
if (waitingForSupervisor.has(r.id)) {
|
|
504
|
+
this.reportedAt.delete(r.id); // a live ask is not a stall; re-evaluate immediately when it settles
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (r.lastAdvanceAt === undefined) continue;
|
|
502
508
|
const prev = this.reportedAt.get(r.id);
|
|
503
509
|
if (prev !== undefined && r.lastAdvanceAt > prev) this.reportedAt.delete(r.id); // advanced ⇒ re-arm
|
|
504
510
|
if (stallMs > 0 && now - r.lastAdvanceAt >= stallMs && !this.reportedAt.has(r.id)) {
|
|
@@ -548,7 +554,7 @@ export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string
|
|
|
548
554
|
* fast {@link buildPeekAlert} stall signal: it catches a leg going the WRONG way (not stalled, just
|
|
549
555
|
* wrong) before it burns the budget, without waking the supervisor every tick.
|
|
550
556
|
*/
|
|
551
|
-
export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number }): string {
|
|
557
|
+
export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string> }): string {
|
|
552
558
|
return (
|
|
553
559
|
`${buildPeekDigest(runs, opts)}\n\n` +
|
|
554
560
|
"Routine check-in — internal status update, not a new task: don't reply just to acknowledge unchanged progress. Continue your current " +
|