pi-durable-subagents 1.0.7 → 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.
@@ -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);
@@ -14,7 +14,7 @@ import { dsaHome, orchLedger, orchLock } from "../paths.js";
14
14
  import { openJournal } from "../kernel/journal.js";
15
15
  import { OsLock } from "../platform/lock.js";
16
16
  import { Engine } from "./engine.js";
17
- import { configPath, configProblem, configStamp, readConfig, recordConfig, watchConfig } from "./config.js";
17
+ import { configPath, configProblem, configStamp, recordConfig, stampedConfig, watchConfig } from "./config.js";
18
18
  /** P2, C4, K6: Acquire the single authority before opening ledgers, recover, and idle-exit. */
19
19
  export async function main(options = {}) {
20
20
  const home = options.home ?? dsaHome();
@@ -26,9 +26,10 @@ export async function main(options = {}) {
26
26
  try {
27
27
  let config = options.config, stamp;
28
28
  if (!config) {
29
- // The stamp is taken before the read: a change made while reading is applied by the first check.
29
+ // One read gives both the version and the settings: with two, a file changed and changed back in between kept
30
+ // the second version in effect while the watcher saw no change.
30
31
  stamp = await configStamp(configPath(home));
31
- const raw = await readConfig(configPath(home));
32
+ const raw = stampedConfig(stamp);
32
33
  const problem = configProblem(raw);
33
34
  if (problem)
34
35
  console.error(`durable-subagents: config.json: ${problem}`);