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.
- package/CHANGELOG.md +36 -0
- package/README.md +8 -2
- package/dist/agent/main/snapshots.js +117 -42
- package/dist/agent/main/tool.js +10 -1
- package/dist/agent/main.js +5 -4
- package/dist/cli/main.js +1 -1
- package/dist/orchestrator/config.js +8 -4
- package/dist/orchestrator/engine.js +23 -2
- package/dist/orchestrator/executor/index.js +126 -36
- package/dist/orchestrator/executor/session.js +8 -0
- package/dist/orchestrator/main.js +4 -3
- package/dist/orchestrator/snapshot.js +273 -177
- package/dist/ui/data.js +42 -6
- package/dist/ui/screen.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
//
|
|
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 =
|
|
32
|
+
const raw = stampedConfig(stamp);
|
|
32
33
|
const problem = configProblem(raw);
|
|
33
34
|
if (problem)
|
|
34
35
|
console.error(`durable-subagents: config.json: ${problem}`);
|