pi-durable-subagents 1.0.8 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.9
4
+
5
+ - `status` shows the model a call actually uses: the model of its last
6
+ provider request. A requested switch not used yet shows as `switching`
7
+ (also in the brief status), a switch the subagent refused as
8
+ `switchFailed`. Before, `status` kept the model the execution started with,
9
+ so a switch that had worked looked as if it had not.
10
+ - `send follow-up` with `model` runs the new generation on that model; it was
11
+ ignored, and the generation continued on the session's model. A send that
12
+ names a model replies with `model` and `effect`: `next-request` (a running
13
+ call switches at its next request), `next-execution` or `next-generation`.
14
+ - `send model` to a call that is asking (hibernated) or still waiting for a
15
+ slot is recorded and applied when it runs again, instead of being refused
16
+ with `call-not-running`.
17
+ A requested model applies once: afterwards the session and the pool choose
18
+ as before. Withdrawing a follow-up that names a model withdraws the switch
19
+ too.
20
+ - A provider's refusal of the content (terms of service, usage policy) fails
21
+ the call at once with that error. It was retried as a lost execution five
22
+ times and reported as `lost ×5`.
23
+
3
24
  ## 1.0.8
4
25
 
5
26
  - pi stays responsive with a long history: the extension's polling in pi's
package/README.md CHANGED
@@ -77,6 +77,12 @@ answer) or failed, and one line per finished workflow. `status` with a wid
77
77
  shows one workflow with outputs clipped; add `key` for one call's full result,
78
78
  or `full: true` for everything. When a run replies `{submitted: {rid}}`
79
79
  (its workflow was not created within 10 s), the rid works wherever a wid does.
80
+ A call's `model` in `status` is the model its last provider request used; a
81
+ requested switch not used yet shows as `switching`, a refused one as
82
+ `switchFailed`. A send naming a model replies with `model` and `effect`
83
+ (`next-request`, `next-execution` or `next-generation`).
84
+ A provider's refusal of the content (terms of service, usage policy) fails the
85
+ call at once with that error instead of retrying it.
80
86
  With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`, `isolation`,
81
87
  `context`, `tools`, `skills` and `once` apply to every step that does not set
82
88
  its own; other call fields there, and any of them beside a workflow script,
@@ -90,9 +96,9 @@ it something. Each verb means one thing, and a refusal says what would work:
90
96
  |---|---|---|
91
97
  | `run` | — | Start one subagent, `tasks` in parallel, a `chain`, or a workflow script. An unknown agent name is refused before anything starts, with the list of agents. |
92
98
  | `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`; To one waiting on its question: it interrupts the question, and the subagent usually asks again; `answer` answers it. |
93
- | `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). |
99
+ | `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). With `model`, that generation runs on it. |
94
100
  | `send answer` | an open question | Answers it once. |
95
- | `send model` | any subagent | Switches its model at the next request. |
101
+ | `send model` | any subagent | A running one switches at its next request; one asking, hibernated or waiting for a slot launches on it when it runs again. |
96
102
  | `stop` | a subagent or a workflow | Final: `stopped`, usage kept, edits left as they are. |
97
103
  | `drain` / `resume` | existing workflows | A reversible hold; runs started later are not held. |
98
104
 
@@ -39,6 +39,13 @@ function call(value, cwd, where) {
39
39
  return spec;
40
40
  }
41
41
  /** v12 §2: Infer unambiguous runs and normalize controls into unchanged wire bodies. */
42
+ /** P12: a send naming a model is answered with that model and when it applies — `next-request` (a running call switches
43
+ * at its next provider request), `next-execution` (a call with no live execution launches on it) or `next-generation`
44
+ * (a follow-up's new generation runs on it). From the orchestrator ledger's `send-note`. */
45
+ export function sendReceipt(ledger, rid) {
46
+ const note = ledger.find(e => e.type === "send-note" && e.rid === rid);
47
+ return note ? { model: String(note.model), effect: String(note.effect) } : {};
48
+ }
42
49
  export function request(args, cwd) {
43
50
  // v12 §2: Infer run only when one launch form is present; never guess a control verb.
44
51
  const launchForms = [args.agent !== undefined || args.task !== undefined, args.tasks !== undefined,
@@ -110,7 +117,9 @@ export function request(args, cwd) {
110
117
  const kind = string(args, "kind");
111
118
  if (!["steer", "follow-up", "answer", "model"].includes(kind))
112
119
  throw new Error("Unsupported send kind");
113
- const body = { to: string(args, "to"), kind, ...(kind === "model" ? { model: string(args, "model") } : { message: string(args, "message") }), ...(args.by === "user" ? { by: "user" } : {}) };
120
+ // follow-up may name the model its continuation runs on (P37); other kinds ignore one.
121
+ const model = kind === "model" || kind === "follow-up" && args.model !== undefined ? { model: string(args, "model") } : {};
122
+ const body = { to: string(args, "to"), kind, ...model, ...(kind === "model" ? {} : { message: string(args, "message") }), ...(args.by === "user" ? { by: "user" } : {}) };
114
123
  const cond = {};
115
124
  if (kind === "answer") {
116
125
  cond.qid = string(args, "qid");
@@ -13,7 +13,7 @@ import { dsaHome, orchInbox, orchLedger, orchLock, outboxRoot } from "../paths.j
13
13
  import { CT, JT } from "../types.js";
14
14
  import { attention, presentText, presented, resolved, unfinishedWorkflow } from "./main/snapshots.js";
15
15
  import { isLive, pausedElsewhere, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
16
- import { parameters, request } from "./main/tool.js";
16
+ import { parameters, request, sendReceipt } from "./main/tool.js";
17
17
  import { discoverAgents } from "../compat/agents.js";
18
18
  let noteSink;
19
19
  /** P16: Queue a UI note for the next boundary without waking the model. */
@@ -303,8 +303,9 @@ export function registerMain(pi, ui) {
303
303
  // The rid is returned so a later send can supersede this one (replaces: [rid]).
304
304
  if (decision.type === "rejected")
305
305
  return { applied: false, reason: sent.kind === "resume" && args.wid === undefined ? resumeElsewhere(String(decision.reason)) : decision.reason, rid: sent.rid };
306
- if (sent.kind !== "run")
307
- return { applied: true, rid: sent.rid };
306
+ if (sent.kind !== "run") {
307
+ return { applied: true, rid: sent.rid, ...sendReceipt(ledger(), sent.rid) };
308
+ }
308
309
  }
309
310
  if (performance.now() >= deadline || signal?.aborted)
310
311
  break;
@@ -324,7 +325,7 @@ export function registerMain(pi, ui) {
324
325
  "Durable asynchronous subagents; run returns {wid} when created (or {submitted:{rid}} while pending). A finished workflow (its notice carries every agent's result) or a question wakes you, so after starting work end your turn: never poll with sleep or repeated status. Crash recovery resumes sessions, not external side effects. Background helper processes (orchestrator, evaluator) exit by themselves about 10 s after all work ends: never kill processes or delete files to 'clean up'. When the user quits pi, this session's running workflows pause (nothing is spent); resume continues them.",
325
326
  "run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name, cwd, usageBudget, maxCalls, inputs. With tasks/chain, top-level model, timeoutMs, budget, isolation, context, tools, skills, once are defaults for every step (a step's own value wins); a workflow/source script sets them per runs.run call. timeoutMs is milliseconds of active time (a number); omit it unless a hard limit is needed. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
326
327
  "agents: list names, descriptions, default models and source for this cwd; use these names for run.",
327
- "send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model switches at next provider request. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
328
+ "send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn; follow-up model:'provider/id' runs that generation on it. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model: a running call switches at its next provider request; an asking, hibernated or queued call launches on it when it runs again; the reply's model/effect (next-request|next-execution|next-generation) says which. status model = model actually used by the last request; switching = requested, not used yet; switchFailed = refused. A provider content refusal (ToS/usage policy) fails the call at once, not retried. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
328
329
  "stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. status: without wid, what runs, asks (with its answer address; hibernated:true holds no slot) or failed, finished workflows one line each, provider slots held/limit and the config in effect; wid: one workflow, outputs clipped; wid+key: one call's full result; full:true: everything. A run's rid from {submitted:{rid}} works wherever a wid is expected. revise wid + workflow/source/args starts a revision.",
329
330
  "Control replies are {applied:true,rid} or {applied:false,reason,rid} when decided; otherwise {submitted:{rid}} after 10s.",
330
331
  ...(agents ? [`Available agents: ${agents}.`] : []),
package/dist/cli/main.js CHANGED
@@ -65,7 +65,7 @@ export function renderStatus(wf) {
65
65
  /** P25, T10: Render the compact status projection shared with the `subagents` tool. */
66
66
  export function renderView(view) {
67
67
  const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.followUps ? " (follow-up running)" : ""}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
68
- ...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.hibernated ? " (hibernated, no slot)" : ""}${c.model ? ` ${c.model}` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}`),
68
+ ...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.hibernated ? " (hibernated, no slot)" : ""}${c.model ? ` ${c.model}` : ""}${c.switching ? ` → ${c.switching} (requested)` : ""}${c.switchFailed ? ` (switch refused: ${c.switchFailed})` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}`),
69
69
  ...w.attention.map(a => ` ${a.kind}: ${JSON.stringify(a.text.split("\n")[0])}`)].join("\n"));
70
70
  if (view.paused)
71
71
  lines.unshift(`${view.paused} (pi-durable-subagents resume)`);
@@ -22,6 +22,7 @@ import { EvaluatorClient } from "./evaluator-client.js";
22
22
  import { Store, revisionEntries, terminalEntry } from "./store.js";
23
23
  import { formatUsage, holdOf, refusedResult, snapshotFromEntries } from "./snapshot.js";
24
24
  import { validateCallSpec } from "../compat/spec.js";
25
+ import { parseModel } from "../compat/model.js";
25
26
  const tail = (text, n) => text.length > n ? `…${text.slice(-(n - 1))}` : text;
26
27
  const charged = (u) => u && (u.input || u.output || u.costUsd) ? formatUsage(u) : undefined;
27
28
  const wakeStatus = (status) => {
@@ -330,12 +331,26 @@ export class Engine {
330
331
  const seal = wf.journal.entries().find(e => e.type === JT.sealed && e.call === from);
331
332
  if (seal && send.kind === 'steer')
332
333
  return { action: 'reject', reason: `finished:${seal.result.status} — use kind "follow-up" to continue it` };
334
+ if (send.kind === 'follow-up' && send.model !== undefined) {
335
+ try {
336
+ if (!parseModel(send.model).provider)
337
+ throw new Error('missing provider');
338
+ }
339
+ catch {
340
+ return { action: 'reject', reason: 'unknown-model' };
341
+ }
342
+ }
333
343
  if (seal && send.kind === 'follow-up') {
334
344
  const gen = Math.max(0, ...wf.journal.entries().filter(e => ['call', 'generation'].includes(e.type) && e.key === entry.key).map(e => Number(e.gen))) + 1;
335
- const opened = await wf.journal.append('generation', { rid: req.rid, key: entry.key, gen, from, spec: entry.spec, revision: wf.revision, opening: { rid: req.rid, kind: send.kind, message: send.message ?? '' } });
345
+ // A follow-up's model replaces the continued session's for this generation and those continuing it.
346
+ const spec = send.model !== undefined ? { ...entry.spec, model: send.model } : entry.spec;
347
+ if (send.model !== undefined)
348
+ await this.note(req.rid, send.model, 'next-generation');
349
+ const opened = await wf.journal.append('generation', { rid: req.rid, key: entry.key, gen, from, spec, revision: wf.revision, opening: { rid: req.rid, kind: send.kind, message: send.message ?? '' }, ...(send.model !== undefined ? { model: send.model } : {}) });
336
350
  this.dispatchGeneration(wf, opened);
337
351
  return { action: 'apply' };
338
352
  }
353
+ // A follow-up naming a model, queued on unfinished work: the executor records its model request with the message.
339
354
  return this.executor.forward(req, this.context(wf, entry));
340
355
  }
341
356
  else if (req.kind === 'stop') {
@@ -504,6 +519,11 @@ export class Engine {
504
519
  this.background(async () => { throw error; });
505
520
  });
506
521
  }
522
+ /** The reply to a send that names a model says which model and when it applies (orchestrator ledger `send-note`). */
523
+ async note(rid, model, effect) {
524
+ if (!this.ledgers.orch.entries().some(e => e.type === 'send-note' && e.rid === rid))
525
+ await this.ledgers.orch.append('send-note', { rid, model, effect });
526
+ }
507
527
  ticket(st, entry) {
508
528
  const spec = entry.spec, agent = st.wf.pins.agents.find(a => a.name === spec.agent);
509
529
  if (!agent)
@@ -511,7 +531,8 @@ export class Engine {
511
531
  return { wid: st.wf.wid, widRev: `${st.wf.wid}@${st.wf.revision}`, key: entry.key, gen: entry.gen,
512
532
  callId: `${st.wf.wid}@${st.wf.revision}/${entry.key}@${entry.gen}`, spec, agent, workflowBudget: st.wf.pins.usageBudget, cwd: resolve(st.wf.cwd, spec.cwd ?? '.'), journal: st.wf.journal,
513
533
  ...(st.wf.pins.origin !== undefined ? { originSession: join(pinnedDir(this.ledgers.home, st.wf.wid), ...(st.wf.revision === 1 ? [] : [`r${st.wf.revision}`]), 'origin.jsonl') } : {}),
514
- ...(entry.type === 'generation' ? { continueFrom: entry.from, opening: entry.opening } : {}) };
534
+ ...(entry.type === 'generation' ? { continueFrom: entry.from, opening: entry.opening } : {}),
535
+ ...(entry.type === 'generation' && typeof entry.model === 'string' ? { model: entry.model } : {}) };
515
536
  }
516
537
  sealed(st, entry) {
517
538
  if (entry.type === 'refused')
@@ -22,7 +22,7 @@ import { buildCallResult } from "../../compat/result.js";
22
22
  import createEffects from "./effects/index.js";
23
23
  import { continueSession } from "./generation.js";
24
24
  import { hibernation, openQuestion } from "./hibernate.js";
25
- import { evidence, fatalProviderError, forgetSession, readSessionState, receiptId, sessionModel } from "./session.js";
25
+ import { evidence, fatalProviderError, refusedByProvider, forgetSession, readSessionState, receiptId, sessionModel } from "./session.js";
26
26
  import { activeTotal } from "./time.js";
27
27
  import { observeExecution } from "./observe.js";
28
28
  import { availableMemory } from "./memory.js";
@@ -41,6 +41,39 @@ const entriesFor = (journal, call) => journal.entries().filter(e => e.call === c
41
41
  const current = (journal, call) => entriesFor(journal, call).findLast(e => e.type === JT.exec)?.exec;
42
42
  const sealed = (journal, call) => entriesFor(journal, call).find(e => e.type === JT.sealed)?.result;
43
43
  const has = (journal, type, exec) => journal.entries().some(e => e.type === type && e.exec === exec);
44
+ /** The rid of the model request a follow-up naming a model makes (P12): derived, so withdrawing or replacing the
45
+ * follow-up withdraws its model request too. */
46
+ export const modelRid = (rid) => contentHash([rid, "model"]);
47
+ /** The model a call was asked to use and has not used yet: a follow-up's `model`, then each model send in order. One the
48
+ * child rejected or that was withdrawn does not count; one already used does not either — the child applied it, or an
49
+ * execution of the call answered with it — so the session's model and the pool's fallback rule again after that.
50
+ * Its next execution launches on it (P12, P37). */
51
+ export function requestedModel(journal, call, followUp) {
52
+ const all = journal.entries(), execs = new Set(all.filter(e => e.type === JT.exec && e.call === call).map(e => String(e.exec)));
53
+ const same = (a, b) => b?.provider === a.provider && b?.id === a.id;
54
+ const usedAfter = (m, index) => all.some((e, i) => i > index && (e.type === "selected" || e.type === "model-used") && execs.has(String(e.exec)) && same(m, e.model));
55
+ let wanted;
56
+ if (followUp) {
57
+ const m = parseModel(followUp);
58
+ if (!usedAfter(m, -1))
59
+ wanted = m;
60
+ }
61
+ for (const [index, e] of all.entries()) {
62
+ if (e.type !== "forward" || e.dest !== call || e.envelope?.kind !== "model")
63
+ continue;
64
+ const delivered = all.find(r => r.type === "forward-delivered" && r.call === call && r.rid2 === e.rid2);
65
+ if (delivered) {
66
+ wanted = undefined;
67
+ continue;
68
+ } // applied by the child (now the session's model) or refused by it
69
+ if (all.some(r => r.type === "forward" && r.dest === call && r.envelope.kind === "withdraw" && r.envelope.body.rids?.includes(String(e.rid2))))
70
+ continue;
71
+ const body = e.envelope.body;
72
+ const m = { provider: body.provider, id: body.model, ...(body.thinking ? { thinking: body.thinking } : {}) };
73
+ wanted = usedAfter(m, index) ? undefined : m;
74
+ }
75
+ return wanted;
76
+ }
44
77
  function address(call) {
45
78
  const match = /^(.*)@(\d+)\/(.*)@(\d+)$/.exec(call);
46
79
  if (!match)
@@ -261,6 +294,11 @@ export default function createExecutor(ledgers, options = {}) {
261
294
  }
262
295
  }
263
296
  /** P7, P27: Record forward-delivered once when a forward's child receipt is first observed; serial sections only. */
297
+ /** The reply to a model send says which model and when it applies (orchestrator ledger `send-note`, once per rid). */
298
+ async function note(rid, model, effect) {
299
+ if (!orch.entries().some(e => e.type === "send-note" && e.rid === rid))
300
+ await orch.append("send-note", { rid, model, effect });
301
+ }
264
302
  async function forwardsDelivered(journal, call, entries) {
265
303
  const all = journal.entries();
266
304
  const open = all.filter(e => e.type === "forward" && e.dest === call &&
@@ -461,6 +499,10 @@ export default function createExecutor(ledgers, options = {}) {
461
499
  const candidates = raw ? resolveModel(raw, config.pools) : [{ id: "" }];
462
500
  const candidate = recorded && candidates.some(m => m.provider === recorded.provider && m.id === recorded.id);
463
501
  const skip = previous && ownSegment && pool && candidate && skipped(pool, recorded);
502
+ // A model the call was asked to use replaces the session's: launched with it, and holding its provider's slot.
503
+ const wanted = requestedModel(t.journal, t.callId, t.model);
504
+ if (wanted && !(recorded && !freshFork && recorded.provider === wanted.provider && recorded.id === wanted.id))
505
+ return { candidates: [wanted], continuation: false, pool: undefined };
464
506
  if (recorded && !freshFork && !skip)
465
507
  return { candidates: [recorded], continuation: true, pool: candidate ? pool : undefined };
466
508
  return { candidates, continuation: false, pool };
@@ -487,11 +529,26 @@ export default function createExecutor(ledgers, options = {}) {
487
529
  }
488
530
  });
489
531
  }
490
- async function switched(exec, event) {
491
- const provider = event.message?.provider;
532
+ /** The model each execution last answered with (`selected`, then `model-used`), cached per execution. */
533
+ const inUse = new Map();
534
+ async function switched(exec, journal, event) {
535
+ const message = event.message, provider = message?.provider;
492
536
  if (!provider)
493
537
  return;
494
538
  await serial(async () => {
539
+ // Evidence of the model in use: the provider and model of each assistant message, recorded when it changes.
540
+ if (message.role === "assistant" && message.model) {
541
+ const name = `${provider}/${message.model}`;
542
+ if (!inUse.has(exec)) {
543
+ const last = journal.entries().findLast(e => (e.type === "selected" || e.type === "model-used") && e.exec === exec)?.model;
544
+ if (last)
545
+ inUse.set(exec, `${last.provider}/${last.id}`);
546
+ }
547
+ if (inUse.get(exec) !== name) {
548
+ await journal.append("model-used", { exec, model: { provider, id: message.model } });
549
+ inUse.set(exec, name);
550
+ }
551
+ }
495
552
  const target = holdings().find(h => h.exec === exec && h.pool === provider && h.reserved);
496
553
  if (!target)
497
554
  return;
@@ -579,6 +636,9 @@ export default function createExecutor(ledgers, options = {}) {
579
636
  return finish(journal, t.callId, exec, makeResult("unknown", "", `Unknown tool outcomes: ${dangling.join(", ")}`));
580
637
  if (has(journal, "settled", exec) && !ev.text && ev.error && fatalProviderError(ev.error))
581
638
  return finish(journal, t.callId, exec, makeResult("failed", "", `Provider error: ${ev.error}`));
639
+ // A refusal of the content is deterministic: the same request is refused again, so it is reported, not retried.
640
+ if (has(journal, "settled", exec) && !ev.text && ev.error && refusedByProvider(ev.error))
641
+ return finish(journal, t.callId, exec, makeResult("failed", "", `Refused by the provider (not retried): ${ev.error.slice(0, 500)}`));
582
642
  await serial(async () => {
583
643
  if (!has(journal, "loss", exec))
584
644
  await journal.append("loss", { exec });
@@ -679,7 +739,7 @@ export default function createExecutor(ledgers, options = {}) {
679
739
  }
680
740
  });
681
741
  }, recordUsage: values => recordUsage(t, values),
682
- switched: event => switched(exec, event), pendingSwitch: () => pendingSwitch(exec),
742
+ switched: event => switched(exec, journal, event), pendingSwitch: () => pendingSwitch(exec),
683
743
  });
684
744
  }
685
745
  finally {
@@ -716,6 +776,9 @@ export default function createExecutor(ledgers, options = {}) {
716
776
  finally {
717
777
  active.delete(ticket.callId);
718
778
  collected.delete(ticket.callId);
779
+ for (const e of inUse.keys())
780
+ if (callOf(e) === ticket.callId)
781
+ inUse.delete(e);
719
782
  forgetSession(callSession(home, ticket.wid, ticket.key, ticket.gen));
720
783
  wake();
721
784
  }
@@ -763,28 +826,70 @@ export default function createExecutor(ledgers, options = {}) {
763
826
  return { action: "apply" };
764
827
  }
765
828
  }
829
+ /** P12: a model request to this call, recorded with the rid given; a reject has no effect. */
830
+ const requestModel = async (rid, model, hash, cond) => {
831
+ let body;
832
+ try {
833
+ const m = parseModel(model);
834
+ if (!m.provider)
835
+ throw new Error("Missing provider");
836
+ body = { provider: m.provider, model: m.id, ...(m.thinking ? { thinking: m.thinking } : {}) };
837
+ }
838
+ catch {
839
+ return { action: "reject", reason: "unknown-model" };
840
+ }
841
+ const envelope = { to: dest, kind: "model", body, ...(cond && Object.keys(cond).length ? { cond } : {}) };
842
+ const rid2 = forwardRid(rid, ctx.widRev, ctx.key, hash);
843
+ const exec = current(ctx.journal, dest), provider = body.provider;
844
+ // P28: with no live execution (not started yet, between executions, hibernated while asking) the model is
845
+ // recorded and the next execution launches on it (`requestedModel`); its slot is acquired then, as for any launch.
846
+ // Launching (`selected`, not `tracked` yet): the child may start on the old model; ask again in a moment.
847
+ const idle = !exec || has(ctx.journal, JT.fenced, exec) || !has(ctx.journal, "selected", exec);
848
+ if (!idle && !has(ctx.journal, "tracked", exec))
849
+ return { action: "reject", reason: "call-starting" };
850
+ if (!idle && pendingSwitch(exec))
851
+ return { action: "reject", reason: "switch-pending" };
852
+ if (!idle) {
853
+ const held = holdings().filter(h => h.exec === exec);
854
+ if (!held.some(h => h.pool === provider)) {
855
+ const target = holdings().filter(h => h.pool === provider);
856
+ if (!capacity({ kind: "provider", holders: target.length, capacity: config.providers?.[provider]?.slots ?? Infinity }))
857
+ return { action: "reject", reason: "provider-full" };
858
+ let slot = 0;
859
+ while (target.some(h => h.slot === slot))
860
+ slot++;
861
+ await orch.append("hold", { pool: provider, slot, exec, reserved: true, rid });
862
+ }
863
+ }
864
+ await note(req.rid, model, idle ? "next-execution" : "next-request");
865
+ const entry = await ctx.journal.append("forward", { rid, rid2, dest, hash, envelope });
866
+ await replayForward(entry);
867
+ if (idle) {
868
+ active.get(dest)?.wake();
869
+ wake();
870
+ }
871
+ return undefined;
872
+ };
766
873
  let kind, body;
874
+ if (req.kind === "send" && req.body.kind === "follow-up" && req.body.model !== undefined) {
875
+ // A follow-up naming a model, queued on unfinished work: the model request and the message are recorded in one
876
+ // section, so a seal cannot come between them (both or neither). A replay finds the model request recorded.
877
+ const mrid = modelRid(req.rid);
878
+ if (!ctx.journal.entries().some(e => e.type === "forward" && e.rid === mrid && e.dest === dest)) {
879
+ const refused = await requestModel(mrid, req.body.model, contentHash([hash, "model"]));
880
+ if (refused)
881
+ return { action: "reject", reason: `model: ${refused.reason}` };
882
+ }
883
+ }
767
884
  if (req.kind === "withdraw") {
768
885
  kind = "withdraw";
769
- const targets = req.body.rids;
886
+ const targets = req.body.rids.flatMap(rid => [rid, modelRid(rid)]);
770
887
  body = { rids: ctx.journal.entries().filter(e => e.type === "forward" && e.dest === dest && targets.includes(String(e.rid))).map(e => String(e.rid2)) };
771
888
  }
772
889
  else if (req.kind === "send") {
773
890
  const send = req.body;
774
891
  kind = send.kind;
775
- if (kind === "model") {
776
- try {
777
- const m = parseModel(send.model ?? "");
778
- if (!m.provider)
779
- throw new Error("Missing provider");
780
- body = { provider: m.provider, model: m.id, ...(m.thinking ? { thinking: m.thinking } : {}) };
781
- }
782
- catch {
783
- return { action: "reject", reason: "unknown-model" };
784
- }
785
- }
786
- else
787
- body = { message: send.message ?? "" };
892
+ body = kind === "model" ? undefined : { message: send.message ?? "" };
788
893
  }
789
894
  else
790
895
  return { action: "reject", reason: "unsupported" };
@@ -797,30 +902,15 @@ export default function createExecutor(ledgers, options = {}) {
797
902
  else
798
903
  delete cond.after;
799
904
  }
905
+ if (kind === "model")
906
+ return (await requestModel(req.rid, req.body.model ?? "", hash, cond)) ?? { action: "apply" };
800
907
  const envelope = { to: dest, kind, body, ...(Object.keys(cond).length ? { cond } : {}) };
801
908
  const rid2 = forwardRid(req.rid, ctx.widRev, ctx.key, hash);
802
- if (kind === "model") {
803
- const exec = current(ctx.journal, dest), provider = body.provider;
804
- if (!exec || has(ctx.journal, JT.fenced, exec) || !has(ctx.journal, "tracked", exec))
805
- return { action: "reject", reason: "call-not-running" };
806
- if (pendingSwitch(exec))
807
- return { action: "reject", reason: "switch-pending" };
808
- const held = holdings().filter(h => h.exec === exec);
809
- if (!held.some(h => h.pool === provider)) {
810
- const target = holdings().filter(h => h.pool === provider);
811
- if (!capacity({ kind: "provider", holders: target.length, capacity: config.providers?.[provider]?.slots ?? Infinity }))
812
- return { action: "reject", reason: "provider-full" };
813
- let slot = 0;
814
- while (target.some(h => h.slot === slot))
815
- slot++;
816
- await orch.append("hold", { pool: provider, slot, exec, reserved: true, rid: req.rid });
817
- }
818
- }
819
909
  const entry = await ctx.journal.append("forward", { rid: req.rid, rid2, dest, hash, envelope });
820
910
  await replayForward(entry);
821
911
  if (kind === "withdraw") {
822
912
  const exec = current(ctx.journal, dest), reservation = exec && pendingSwitch(exec);
823
- if (reservation && req.body.rids.includes(String(reservation.rid)))
913
+ if (reservation && req.body.rids.some(rid => rid === reservation.rid || modelRid(rid) === reservation.rid))
824
914
  active.get(dest)?.wake();
825
915
  }
826
916
  return { action: "apply" };
@@ -111,6 +111,14 @@ export function evidence(entries, exec) {
111
111
  export function fatalProviderError(text) {
112
112
  return /\b402\b|insufficient[_ ]?(quota|balance|funds)|quota (exceeded|exhausted)|billing|credit balance|额度|余额|usage limit/i.test(text);
113
113
  }
114
+ /** A refusal of the request's content (terms of service, usage or content policy): the same request is refused again,
115
+ * on this provider and usually on another, so it is reported at once instead of retried as a lost execution. */
116
+ export function refusedByProvider(text) {
117
+ // A content filter that is down ("temporarily unavailable, please retry") is a transient failure, not a refusal.
118
+ if (/temporar|unavailable|try again|retry|timed? ?out|overloaded/i.test(text))
119
+ return false;
120
+ return /terms of service|usage polic(y|ies)|acceptable use|content[_ ]?(policy|filter|management policy)|safety (system|filter)|flagged as (unsafe|harmful)/i.test(text);
121
+ }
114
122
  /** P13, C8: Restore the effective provider from the native session's model changes. */
115
123
  export function sessionModel(entries) {
116
124
  const last = entries.findLast(e => e.type === "model_change" && e.provider && e.modelId);
@@ -152,6 +152,12 @@ function snapshotReducer(wid, entries) {
152
152
  if (call && call.phase === "queued")
153
153
  call.phase = "running";
154
154
  }
155
+ else if (e.type === "model-used") {
156
+ // Evidence of a switch: the execution answered with another model than it launched with.
157
+ const call = byExec.get(String(e.exec)), m = e.model;
158
+ if (call && m)
159
+ call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
160
+ }
155
161
  else if (e.type === "observation") {
156
162
  // Only what the agent did counts as activity; tracker scans and time checkpoints are bookkeeping.
157
163
  const call = byExec.get(String(e.exec));
@@ -195,6 +201,13 @@ function snapshotReducer(wid, entries) {
195
201
  const list = sends.get(String(e.dest)) ?? [];
196
202
  list.push(send);
197
203
  sends.set(String(e.dest), list);
204
+ // A withdrawn model request no longer stands (the executor ignores it for the next launch as well).
205
+ if (envelope?.kind === "withdraw")
206
+ for (const rid2 of envelope.body?.rids ?? []) {
207
+ const target = byRid2.get(`${e.dest}\n${rid2}`);
208
+ if (target?.kind === "model" && target.state === "pending")
209
+ target.reason = "withdrawn";
210
+ }
198
211
  byRid2.set(`${e.dest}\n${e.rid2}`, send);
199
212
  byRid2.set(String(e.rid2), send);
200
213
  }
@@ -239,9 +252,11 @@ function snapshotReducer(wid, entries) {
239
252
  const pending = forwarded.filter(pendingMessage).length;
240
253
  if (pending)
241
254
  c.pending = pending;
242
- const switching = c.phase !== "sealed" ? forwarded.findLast(s => s.kind === "model" && s.state === "pending")?.model : undefined;
243
- if (switching && switching !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
244
- c.switching = switching;
255
+ const last = c.phase !== "sealed" && !retired.has(c.callId) ? forwarded.findLast(s => s.kind === "model" && s.reason !== "withdrawn") : undefined;
256
+ if (last?.reason !== undefined)
257
+ c.switchFailed = `${last.model} (${last.reason})`;
258
+ else if (last && last.state !== "retired" && last.model !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
259
+ c.switching = last.model;
245
260
  }
246
261
  }
247
262
  const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
@@ -404,7 +419,7 @@ export function compactWorkflow(wf) {
404
419
  calls: wf.calls.map(c => {
405
420
  const r = c.result, last = r?.output?.split("\n").map(l => l.trim()).filter(Boolean).at(-1);
406
421
  return { key: c.key, gen: c.gen, callId: c.callId, phase: c.phase, ...(r ? { status: r.status, ok: r.ok } : {}),
407
- ...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
422
+ ...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(c.switchFailed ? { switchFailed: c.switchFailed } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
408
423
  ...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}), ...(c.hibernated ? { hibernated: true } : {}) };
409
424
  }),
410
425
  attention: wf.attention.map(a => ({ id: a.id, rev: a.rev, kind: a.kind, text: clip(a.text, 300), ...(a.call ? { call: a.call } : {}), ...(a.qid ? { qid: a.qid } : {}) })),
@@ -498,7 +513,8 @@ export function statusBrief(home, options = {}) {
498
513
  ...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
499
514
  ...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
500
515
  ...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}),
501
- ...(c.hibernated ? { hibernated: true } : {}) };
516
+ ...(c.hibernated ? { hibernated: true } : {}),
517
+ ...(live && c.switching ? { switching: c.switching } : {}), ...(live && c.switchFailed ? { switchFailed: c.switchFailed } : {}) };
502
518
  });
503
519
  const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
504
520
  ...(w.calls.some(c => c.callId === a.call && c.hibernated) ? { hibernated: true } : {}), question: clip(a.text, 300) }));
package/dist/ui/screen.js CHANGED
@@ -565,7 +565,7 @@ export class SubagentScreen {
565
565
  const facts = this.data.facts.get(c.callId), active = w.calls.filter(c => c.phase !== "sealed"), done = w.calls.length - active.length;
566
566
  const tabs = size.width < 60 ? `${c.key} ${w.calls.indexOf(c) + 1}/${w.calls.length}` : `${[...active.map(c => c.key), ...(done ? [`${done} done`] : [])].join(" · ")} ← → switch`;
567
567
  const tools = toolCount(facts?.tools), pending = pendingText(c.pending), rule = this.theme.fg("borderMuted", "─".repeat(size.width));
568
- const switching = c.switching ? ` → ${this.name(c.switching)} (at the end of this step)` : "";
568
+ const switching = c.switching ? ` → ${this.name(c.switching)} (requested)` : "";
569
569
  const head = [tabs, `${label(c)} · ${this.name(facts?.model ?? c.model)} ▾${switching} · ${facts?.thinking ?? "off"} ▾${tools ? ` · ${tools}` : ""}${pending ? ` · ${pending}` : ""}`,
570
570
  this.theme.fg("dim", this.spend(c, facts)), rule];
571
571
  const asking = w.attention.some(a => a.kind === "question" && a.call === c.callId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-durable-subagents",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",