@aeondave/pi-persona 1.15.2 → 1.16.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/AGENTS.md +30 -6
- package/CHANGELOG.md +82 -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 +8 -0
- package/package.json +11 -9
- 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 +209 -49
- 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/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
|
@@ -6,4 +6,12 @@ The pi-persona producer writes semantic projections under `telemetry/v2/<workspa
|
|
|
6
6
|
|
|
7
7
|
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
8
|
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
9
17
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeondave/pi-persona",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
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.",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"README.md",
|
|
51
51
|
"AGENTS.md",
|
|
52
52
|
"CHANGELOG.md",
|
|
53
|
+
"SECURITY.md",
|
|
53
54
|
"LICENSE"
|
|
54
55
|
],
|
|
55
56
|
"scripts": {
|
|
@@ -84,20 +85,21 @@
|
|
|
84
85
|
}
|
|
85
86
|
},
|
|
86
87
|
"engines": {
|
|
87
|
-
"node": ">=22"
|
|
88
|
+
"node": ">=22.19.0"
|
|
88
89
|
},
|
|
89
90
|
"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.
|
|
91
|
+
"@earendil-works/pi-agent-core": "1.0.0",
|
|
92
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
93
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
94
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
94
95
|
"@types/node": "^22.20.4",
|
|
96
|
+
"fast-check": "^4.10.2",
|
|
95
97
|
"tsx": "^4.23.13",
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
+
"typebox": "1.3.34",
|
|
99
|
+
"typescript": "^7.0.2"
|
|
98
100
|
},
|
|
99
101
|
"pi": {
|
|
100
|
-
"image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.
|
|
102
|
+
"image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.16.0/assets/banner.png",
|
|
101
103
|
"video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
|
|
102
104
|
"extensions": [
|
|
103
105
|
"./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 " +
|
package/src/engine/inproc.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { assignedIdentityPrompt } from "../core/naming.ts";
|
|
|
30
30
|
import { attributeInbound } from "../core/fence.ts";
|
|
31
31
|
import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
|
|
32
32
|
import { roleHint } from "../orchestration/roster.ts";
|
|
33
|
-
import { type AgentRunSpec, isPositiveFiniteMs, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
33
|
+
import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
|
|
34
34
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
35
35
|
import { looksLikeProviderError } from "./errors.ts";
|
|
36
36
|
import { nextChildHandle } from "./handles.ts";
|
|
@@ -94,8 +94,10 @@ export interface InProcessDeps {
|
|
|
94
94
|
cwd: string;
|
|
95
95
|
/** Pi's global agent dir (~/.pi/agent); defaults to cwd if omitted (tests). */
|
|
96
96
|
agentDir?: string;
|
|
97
|
-
/** Per-agent model override (e.g. a persona's configured ensemble models).
|
|
98
|
-
|
|
97
|
+
/** Per-agent model override (e.g. a persona's configured ensemble models). `role` is the
|
|
98
|
+
* on-the-fly role this run carries, when it has one, so a member's own assignment key
|
|
99
|
+
* (`agent#hash`) resolves ahead of the bare agent's. */
|
|
100
|
+
modelFor?: (agent: string, role?: string) => string | undefined;
|
|
99
101
|
/** The session/loader model — the last-resort fallback when an agent declares none
|
|
100
102
|
* (the child engine gets this for free from `pi -p`'s default; inproc must pass it). */
|
|
101
103
|
defaultModel?: string;
|
|
@@ -319,10 +321,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
319
321
|
const requestedDef = requestedContract ? pinnedDef(requestedContract) : undefined;
|
|
320
322
|
if (requestedContract && !requestedDef) return unknownContractFailure(spec.agent, requestedContract, deps.listContracts?.() ?? []);
|
|
321
323
|
|
|
322
|
-
const
|
|
324
|
+
const picked = spec.model ? undefined : deps.modelFor?.(spec.agent, spec.role);
|
|
325
|
+
const ref = spec.model ?? picked ?? cfg.model ?? deps.defaultModel;
|
|
323
326
|
const model = resolveModel(deps.modelRegistry, ref);
|
|
324
327
|
if (!model) {
|
|
325
|
-
const src = spec.model ? "spec" :
|
|
328
|
+
const src = spec.model ? "spec" : picked ? "agent picker" : cfg.model ? "agent config" : "default";
|
|
326
329
|
// Make the failure self-correcting: name a few real registry refs (nearest
|
|
327
330
|
// matches first) so the supervisor's retry can pick a valid model at once.
|
|
328
331
|
const all = deps.modelRegistry.getAll().map((m) => `${m.provider}/${m.id}`);
|
|
@@ -681,7 +684,9 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
681
684
|
|
|
682
685
|
// Steering: the in-process engine can inject a user message into the running
|
|
683
686
|
// agent — the v0.4 payoff the one-shot child engine can't do.
|
|
684
|
-
|
|
687
|
+
const waitingHandle = childHandle;
|
|
688
|
+
const waitingBus = deps.bus;
|
|
689
|
+
const steer: SteerFn = Object.assign((text: string): boolean => {
|
|
685
690
|
if (!steerable) return false;
|
|
686
691
|
const trimmed = text.trim();
|
|
687
692
|
if (!trimmed) return false;
|
|
@@ -691,7 +696,10 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
691
696
|
} catch {
|
|
692
697
|
return false;
|
|
693
698
|
}
|
|
699
|
+
}, {
|
|
700
|
+
isWaitingForSupervisor: () => waitingHandle !== undefined && waitingBus?.hasPendingAskFrom(waitingHandle) === true,
|
|
694
701
|
});
|
|
702
|
+
onSteerable?.(steer);
|
|
695
703
|
|
|
696
704
|
// Delivery bridge: bus messages addressed to this child are steered into its live
|
|
697
705
|
// session as fenced, attributed user turns — attribution stays OUTSIDE the fence so
|
package/src/extension/engine.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { RunLimits } from "../core/capabilities.ts";
|
|
|
9
9
|
import type { PiPersonaConfig } from "../core/config.ts";
|
|
10
10
|
import { isThinkingLevel } from "../core/types.ts";
|
|
11
11
|
import { personaModels, type PersonaConfigStore } from "../persona/config-store.ts";
|
|
12
|
+
import { participantKey } from "../persona/model-participants.ts";
|
|
12
13
|
import type { PersonaController, PersonaHost } from "../persona/controller.ts";
|
|
13
14
|
import { type EngineAdapterBroker, type EngineAdapterDeps, makeEngine } from "../engine/adapter.ts";
|
|
14
15
|
import { captureStatus, diffStatus, findGitRoot, renderChangeReport } from "../engine/change-report.ts";
|
|
@@ -50,7 +51,17 @@ export interface BuildEngineDeps {
|
|
|
50
51
|
gitExec?: GitExec;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
export type BuildEngine = (
|
|
54
|
+
export type BuildEngine = (
|
|
55
|
+
signal?: AbortSignal,
|
|
56
|
+
onProgress?: (s: ProgressSnapshot) => void,
|
|
57
|
+
engOpts?: {
|
|
58
|
+
async?: boolean;
|
|
59
|
+
/** Wrap the engine in `withModelFallback` (default true). Strategy/flow builds pass
|
|
60
|
+
* `false`: there the SDK's own main-model recovery is the ONE reroute, so an arbitrary
|
|
61
|
+
* provider must not be substituted underneath a member the user pinned. */
|
|
62
|
+
providerFallback?: boolean;
|
|
63
|
+
},
|
|
64
|
+
) => StrategyEngine;
|
|
54
65
|
|
|
55
66
|
export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
56
67
|
return (signal, onProgress, engOpts) => {
|
|
@@ -63,9 +74,18 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
63
74
|
// A named contract file (contracts/<name>.contract.json) wins; "default" is the built-in.
|
|
64
75
|
const contracts = (n: string): ContractDef | undefined => contractDefs[n] ?? (n === "default" ? DEFAULT_CONTRACT : undefined);
|
|
65
76
|
const contractNames = (): string[] => installedContractNames(contractDefs);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
// A per-persona model assignment. The lookup key MUST match the one the picker saved
|
|
78
|
+
// (`persona/model-participants.ts`): a role member's own key first, then the bare agent
|
|
79
|
+
// key, so assignments saved before roles existed keep applying.
|
|
80
|
+
const persona = controller.activePersona?.name;
|
|
81
|
+
const savedModels = persona ? personaModels(personaConfigs, persona) : undefined;
|
|
82
|
+
const modelFor = (agent: string, role?: string): string | undefined => {
|
|
83
|
+
const trimmed = role?.trim();
|
|
84
|
+
if (trimmed) {
|
|
85
|
+
const own = savedModels?.[participantKey(agent, trimmed)];
|
|
86
|
+
if (own) return own;
|
|
87
|
+
}
|
|
88
|
+
return savedModels?.[agent];
|
|
69
89
|
};
|
|
70
90
|
// The main model thinks adaptively (it picks effort by difficulty); a spawned child
|
|
71
91
|
// can't inherit "adaptive" if its model doesn't support it, so give children an
|
|
@@ -184,6 +204,7 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
|
|
|
184
204
|
// switch on error". No ctx (no registry) ⇒ pass through. Each attempt still runs through
|
|
185
205
|
// worktree isolation + steering below.
|
|
186
206
|
const wrapFallback = (eng: StrategyEngine): StrategyEngine => {
|
|
207
|
+
if (engOpts?.providerFallback === false) return eng; // the SDK owns recovery there
|
|
187
208
|
if (!lastCtx) return eng;
|
|
188
209
|
const prefer = lastCtx.model?.provider;
|
|
189
210
|
return withModelFallback(eng, {
|