@deepstrike/sdk 0.2.35 → 0.2.37
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/README.md +6 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/os/public.d.ts +1 -1
- package/dist/os/public.js +1 -1
- package/dist/runtime/facade.js +11 -11
- package/dist/runtime/kernel-event-log.d.ts +0 -6
- package/dist/runtime/kernel-event-log.js +38 -62
- package/dist/runtime/kernel-step.d.ts +11 -0
- package/dist/runtime/kernel-step.js +12 -0
- package/dist/runtime/large-result-spool.d.ts +7 -0
- package/dist/runtime/large-result-spool.js +25 -0
- package/dist/runtime/loop-driver.d.ts +108 -0
- package/dist/runtime/loop-driver.js +198 -0
- package/dist/runtime/os-snapshot.d.ts +0 -1
- package/dist/runtime/os-snapshot.js +0 -19
- package/dist/runtime/reactive-session.d.ts +5 -2
- package/dist/runtime/reactive-session.js +17 -4
- package/dist/runtime/run-group.d.ts +9 -0
- package/dist/runtime/run-group.js +18 -4
- package/dist/runtime/runner.d.ts +151 -12
- package/dist/runtime/runner.js +568 -155
- package/dist/runtime/session-log.d.ts +31 -54
- package/dist/runtime/session-repair.d.ts +29 -7
- package/dist/runtime/session-repair.js +37 -9
- package/dist/runtime/sub-agent-orchestrator.d.ts +12 -0
- package/dist/runtime/sub-agent-orchestrator.js +54 -30
- package/dist/runtime/workflow-control-flow.d.ts +10 -2
- package/dist/runtime/workflow-control-flow.js +27 -6
- package/dist/signals/gateway.d.ts +4 -2
- package/dist/signals/gateway.js +8 -1
- package/dist/types/agent.d.ts +40 -2
- package/dist/types/agent.js +25 -1
- package/dist/types.d.ts +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Fold the loop's session log into resumable pacing state — zero new storage. DW-5: the judge's
|
|
2
|
+
* override budget folds too, so a crash/restart can't grant the verdictFn fresh overrides. */
|
|
3
|
+
export function foldLoopState(events) {
|
|
4
|
+
let roundsCompleted = 0;
|
|
5
|
+
let pendingWakeAtMs;
|
|
6
|
+
let lastPace;
|
|
7
|
+
let overridesUsed = 0;
|
|
8
|
+
for (const { event } of events) {
|
|
9
|
+
if (event.kind === "round_paced") {
|
|
10
|
+
roundsCompleted = Math.max(roundsCompleted, event.round);
|
|
11
|
+
lastPace = { action: event.action, reason: event.reason };
|
|
12
|
+
pendingWakeAtMs = event.action === "sleep" ? event.wake_at_ms : undefined;
|
|
13
|
+
if (event.reason.startsWith("verdict override"))
|
|
14
|
+
overridesUsed += 1;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { roundsCompleted, pendingWakeAtMs, lastPace, overridesUsed };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* DW-6 completion→wake bridge, composed from two existing seams (zero new mechanism): a `sleeper`
|
|
21
|
+
* that races the timer against an L0 recipient-addressed signal on the shared gateway. Ingest a
|
|
22
|
+
* signal with `recipient: loopId` (a subagent/workflow completion, a webhook) and the sleeping loop
|
|
23
|
+
* wakes into its next round immediately — where the SAME queued signal then reaches the model
|
|
24
|
+
* through the kernel's normal signal path, so the wake reason is visible in-round.
|
|
25
|
+
*/
|
|
26
|
+
export function signalAwareSleeper(gateway, loopId) {
|
|
27
|
+
return (delayMs) => new Promise(resolve => {
|
|
28
|
+
let settled = false;
|
|
29
|
+
const settle = (v) => {
|
|
30
|
+
if (settled)
|
|
31
|
+
return;
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
unsubscribe();
|
|
35
|
+
resolve(v);
|
|
36
|
+
};
|
|
37
|
+
const unsubscribe = gateway.onSignal(sig => {
|
|
38
|
+
if (sig.recipient === loopId)
|
|
39
|
+
settle(true);
|
|
40
|
+
});
|
|
41
|
+
const timer = setTimeout(() => settle(true), Math.max(0, delayMs));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export class LoopDriver {
|
|
45
|
+
runner;
|
|
46
|
+
spec;
|
|
47
|
+
overridesUsed = 0;
|
|
48
|
+
constructor(runner, spec) {
|
|
49
|
+
this.runner = runner;
|
|
50
|
+
this.spec = spec;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Drive rounds until the loop stops or goes dormant. Resumable by construction:
|
|
54
|
+
* the round count and any pending wake are folded from the session log, so
|
|
55
|
+
* calling `run()` again after a crash / on a stateless host continues in place.
|
|
56
|
+
*/
|
|
57
|
+
async run() {
|
|
58
|
+
const { loopId } = this.spec;
|
|
59
|
+
const log = this.runner.hostOptions.sessionLog;
|
|
60
|
+
// Resume: fold prior rounds + pending wake + the judge's used overrides from the transcript
|
|
61
|
+
// (DW-5: a crash/restart must not refill the verdictFn's override budget).
|
|
62
|
+
const prior = foldLoopState(await log.read(loopId));
|
|
63
|
+
let round = prior.roundsCompleted;
|
|
64
|
+
this.overridesUsed = Math.max(this.overridesUsed, prior.overridesUsed);
|
|
65
|
+
if (prior.pendingWakeAtMs !== undefined) {
|
|
66
|
+
const remaining = prior.pendingWakeAtMs - Date.now();
|
|
67
|
+
if (remaining > 0) {
|
|
68
|
+
const slept = await this.sleep(remaining, prior.pendingWakeAtMs);
|
|
69
|
+
if (!slept) {
|
|
70
|
+
return {
|
|
71
|
+
loopId, roundsCompleted: round, stopped: false,
|
|
72
|
+
state: "dormant", wakeAtMs: prior.pendingWakeAtMs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let feedback;
|
|
78
|
+
for (;;) {
|
|
79
|
+
round += 1;
|
|
80
|
+
// Driver-side round-cap backstop: with a RunGroup, the kernel trap coerces via the
|
|
81
|
+
// seeded ledger; without one, this is the only max_rounds enforcement point.
|
|
82
|
+
if (this.spec.maxRounds !== undefined && round > this.spec.maxRounds) {
|
|
83
|
+
return {
|
|
84
|
+
loopId, roundsCompleted: round - 1, stopped: true, state: "stopped",
|
|
85
|
+
lastPace: { action: "stop", reason: `max_rounds=${this.spec.maxRounds} exhausted` },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
await log.append(loopId, { kind: "round_started", round, goal: this.spec.goal });
|
|
89
|
+
const goal = feedback
|
|
90
|
+
? `${this.spec.goal}\n\n[LOOP FEEDBACK round ${round - 1}] ${feedback}`
|
|
91
|
+
: this.spec.goal;
|
|
92
|
+
feedback = undefined;
|
|
93
|
+
let pace;
|
|
94
|
+
let status;
|
|
95
|
+
// ONE round = one bounded kernel run under the stable loop session id. The
|
|
96
|
+
// kernel's pacing trap adjudicates the model's pace proposal; we consume it
|
|
97
|
+
// from the done event. runSpec.loopRound arms the trap + the pace tool.
|
|
98
|
+
const priorRunSpec = this.runner.hostOptions.runSpec;
|
|
99
|
+
this.runner.hostOptions.runSpec = {
|
|
100
|
+
identity: { agentId: this.runner.hostOptions.agentId ?? "loop", sessionId: loopId, isSubAgent: false },
|
|
101
|
+
role: "custom",
|
|
102
|
+
goal,
|
|
103
|
+
...(priorRunSpec ?? {}),
|
|
104
|
+
loopRound: {
|
|
105
|
+
maxRounds: this.spec.maxRounds,
|
|
106
|
+
minSleepMs: this.spec.minSleepMs,
|
|
107
|
+
maxSleepMs: this.spec.maxSleepMs,
|
|
108
|
+
defaultAction: this.spec.defaultAction,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
// With a RunGroup configured, run() seeds the kernel trap's round base from the
|
|
112
|
+
// group ledger (the driver charges rounds:1 per round below) — max_rounds coercion
|
|
113
|
+
// then happens IN-KERNEL; the check above is the ungrouped backstop.
|
|
114
|
+
try {
|
|
115
|
+
for await (const evt of this.runner.run({
|
|
116
|
+
sessionId: loopId,
|
|
117
|
+
goal,
|
|
118
|
+
criteria: this.spec.criteria,
|
|
119
|
+
})) {
|
|
120
|
+
this.spec.onEvent?.(round, evt);
|
|
121
|
+
if (evt.type === "done") {
|
|
122
|
+
const d = evt;
|
|
123
|
+
status = d.status;
|
|
124
|
+
pace = d.paceDecision;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
this.runner.hostOptions.runSpec = priorRunSpec;
|
|
130
|
+
}
|
|
131
|
+
// Missing pace (old kernel / hard failure): stop and surface — nothing nags.
|
|
132
|
+
const decision = pace ?? {
|
|
133
|
+
action: "stop",
|
|
134
|
+
reason: `round ended without a pace decision (status: ${status ?? "unknown"})`,
|
|
135
|
+
};
|
|
136
|
+
// Cross-round done-gate: a stop proposal may be overridden K times by the judge.
|
|
137
|
+
let finalDecision = decision;
|
|
138
|
+
if (finalDecision.action === "stop"
|
|
139
|
+
&& this.spec.verdictFn
|
|
140
|
+
&& this.overridesUsed < (this.spec.maxVerdictOverrides ?? 2)) {
|
|
141
|
+
try {
|
|
142
|
+
const verdict = await this.spec.verdictFn({ loopId, round, reason: finalDecision.reason });
|
|
143
|
+
if (!verdict.pass) {
|
|
144
|
+
this.overridesUsed += 1;
|
|
145
|
+
feedback = verdict.feedback ?? "verdict failed — keep iterating on the goal";
|
|
146
|
+
finalDecision = {
|
|
147
|
+
action: "continue",
|
|
148
|
+
reason: `verdict override ${this.overridesUsed}: ${verdict.feedback ?? "not done yet"}`,
|
|
149
|
+
coercedFrom: `stop (${finalDecision.reason})`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch { /* judge errs-open: the stop stands */ }
|
|
154
|
+
}
|
|
155
|
+
const wakeAtMs = finalDecision.action === "sleep"
|
|
156
|
+
? Date.now() + (finalDecision.delayMs ?? 60_000)
|
|
157
|
+
: undefined;
|
|
158
|
+
await log.append(loopId, {
|
|
159
|
+
kind: "round_paced",
|
|
160
|
+
round,
|
|
161
|
+
action: finalDecision.action,
|
|
162
|
+
...(finalDecision.delayMs !== undefined ? { delay_ms: finalDecision.delayMs } : {}),
|
|
163
|
+
...(wakeAtMs !== undefined ? { wake_at_ms: wakeAtMs } : {}),
|
|
164
|
+
reason: finalDecision.reason,
|
|
165
|
+
...(finalDecision.coercedFrom ? { coerced_from: finalDecision.coercedFrom } : {}),
|
|
166
|
+
});
|
|
167
|
+
// Lifetime governance: one round = one group charge on the rounds axis.
|
|
168
|
+
const group = this.runner.hostOptions.runGroup;
|
|
169
|
+
if (group)
|
|
170
|
+
await group.budgetStore.charge(group.id, { rounds: 1 });
|
|
171
|
+
if (finalDecision.action === "stop") {
|
|
172
|
+
return {
|
|
173
|
+
loopId, roundsCompleted: round, stopped: true, state: "stopped",
|
|
174
|
+
lastPace: finalDecision, lastStatus: status,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (finalDecision.action === "sleep" && wakeAtMs !== undefined) {
|
|
178
|
+
const slept = await this.sleep(wakeAtMs - Date.now(), wakeAtMs);
|
|
179
|
+
if (!slept) {
|
|
180
|
+
return {
|
|
181
|
+
loopId, roundsCompleted: round, stopped: false, state: "dormant",
|
|
182
|
+
lastPace: finalDecision, lastStatus: status, wakeAtMs,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// continue → next round immediately
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
sleep(delayMs, wakeAtMs) {
|
|
190
|
+
if (this.spec.sleeper)
|
|
191
|
+
return Promise.resolve(this.spec.sleeper(delayMs, wakeAtMs));
|
|
192
|
+
return new Promise(resolve => setTimeout(() => resolve(true), Math.max(0, delayMs)));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Facade: run a self-pacing loop agent (joins runAgent/runFanout as an entry point). */
|
|
196
|
+
export async function runLoop(runner, spec) {
|
|
197
|
+
return new LoopDriver(runner, spec).run();
|
|
198
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { categoryForKind, primitiveForKind } from "./kernel-event-log.js";
|
|
2
1
|
const KERNEL_KINDS = new Set([
|
|
3
2
|
"compressed",
|
|
4
3
|
"page_out",
|
|
@@ -16,7 +15,6 @@ const KERNEL_KINDS = new Set([
|
|
|
16
15
|
"agent_process_changed",
|
|
17
16
|
"milestone_advanced",
|
|
18
17
|
"milestone_blocked",
|
|
19
|
-
"milestone_evidence",
|
|
20
18
|
"memory_written",
|
|
21
19
|
"memory_queried",
|
|
22
20
|
"memory_validation_failed",
|
|
@@ -109,20 +107,3 @@ export function rebuildOsSnapshotFromSessionEvents(events) {
|
|
|
109
107
|
}
|
|
110
108
|
return snap;
|
|
111
109
|
}
|
|
112
|
-
export function sessionLogHasRequiredCategories(events) {
|
|
113
|
-
for (const event of events) {
|
|
114
|
-
if (!KERNEL_KINDS.has(event.kind))
|
|
115
|
-
continue;
|
|
116
|
-
const cat = event.category;
|
|
117
|
-
if (!cat)
|
|
118
|
-
return false;
|
|
119
|
-
if (cat !== categoryForKind(event.kind))
|
|
120
|
-
return false;
|
|
121
|
-
const prim = event.primitive;
|
|
122
|
-
if (prim !== undefined) {
|
|
123
|
-
if (prim !== primitiveForKind(event.kind))
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
@@ -111,8 +111,11 @@ export declare class ReactiveSession {
|
|
|
111
111
|
private getRunner;
|
|
112
112
|
private driveTurn;
|
|
113
113
|
/**
|
|
114
|
-
* Rebuild a session from a persisted `RunGroup`: load its members (lineage) as peers. The
|
|
115
|
-
* continuity comes from the (persistent) `EventStream`. Turn-policy cursor state is not
|
|
114
|
+
* Rebuild a session from a persisted `RunGroup`: load its PEER members (lineage) as peers. The
|
|
115
|
+
* blackboard continuity comes from the (persistent) `EventStream`. Turn-policy cursor state is not
|
|
116
|
+
* restored. W-N5: vehicle members (workflow envelopes, `wf-node*` children, loop iterations) share
|
|
117
|
+
* the governance domain but are NOT personas — resuming them as peers would resurrect phantoms.
|
|
118
|
+
* A legacy membership with no kind tags falls back to resuming every member.
|
|
116
119
|
*/
|
|
117
120
|
static resume(opts: ReactiveSessionOptions & {
|
|
118
121
|
peerSpecs?: Record<string, ReactivePeerSpec>;
|
|
@@ -17,7 +17,13 @@ export class ReactiveSession {
|
|
|
17
17
|
/** Register a peer persona and record it in the group membership (lineage). */
|
|
18
18
|
addPeer(personaId, spec = {}) {
|
|
19
19
|
this.peerSpecs.set(personaId, spec);
|
|
20
|
-
|
|
20
|
+
// W-N5: tagged "peer" so resume() can tell personas apart from vehicle sessions (workflow
|
|
21
|
+
// envelopes / wf-node children / loop iterations) that share the same governance domain.
|
|
22
|
+
void this.opts.runGroup.budgetStore.join(this.opts.runGroup.id, {
|
|
23
|
+
sessionId: personaId,
|
|
24
|
+
role: spec.role,
|
|
25
|
+
kind: "peer",
|
|
26
|
+
});
|
|
21
27
|
}
|
|
22
28
|
peers() {
|
|
23
29
|
return [...this.peerSpecs.keys()];
|
|
@@ -95,12 +101,19 @@ export class ReactiveSession {
|
|
|
95
101
|
return collectText(runner.run({ sessionId: personaId, goal }));
|
|
96
102
|
}
|
|
97
103
|
/**
|
|
98
|
-
* Rebuild a session from a persisted `RunGroup`: load its members (lineage) as peers. The
|
|
99
|
-
* continuity comes from the (persistent) `EventStream`. Turn-policy cursor state is not
|
|
104
|
+
* Rebuild a session from a persisted `RunGroup`: load its PEER members (lineage) as peers. The
|
|
105
|
+
* blackboard continuity comes from the (persistent) `EventStream`. Turn-policy cursor state is not
|
|
106
|
+
* restored. W-N5: vehicle members (workflow envelopes, `wf-node*` children, loop iterations) share
|
|
107
|
+
* the governance domain but are NOT personas — resuming them as peers would resurrect phantoms.
|
|
108
|
+
* A legacy membership with no kind tags falls back to resuming every member.
|
|
100
109
|
*/
|
|
101
110
|
static async resume(opts) {
|
|
102
111
|
const session = new ReactiveSession(opts);
|
|
103
|
-
|
|
112
|
+
const members = await opts.runGroup.budgetStore.members(opts.runGroup.id);
|
|
113
|
+
const anyTagged = members.some(m => m.kind !== undefined);
|
|
114
|
+
for (const member of members) {
|
|
115
|
+
if (anyTagged && member.kind !== "peer")
|
|
116
|
+
continue;
|
|
104
117
|
session.peerSpecs.set(member.sessionId, opts.peerSpecs?.[member.sessionId] ?? { role: member.role });
|
|
105
118
|
}
|
|
106
119
|
return session;
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import type { SessionLog } from "./session-log.js";
|
|
19
19
|
/** Cumulative resources spent across a run group. */
|
|
20
20
|
export interface GroupLedger {
|
|
21
|
+
/** ③ loop-agent rounds completed across the group (seeds the pacing trap's max_rounds). */
|
|
22
|
+
roundsCompleted?: number;
|
|
21
23
|
/** Total tokens spent by all members. */
|
|
22
24
|
tokensSpent: number;
|
|
23
25
|
/** Total sub-agents spawned by all members (running + completed). */
|
|
@@ -27,11 +29,18 @@ export interface GroupLedger {
|
|
|
27
29
|
export interface GroupCharge {
|
|
28
30
|
tokens?: number;
|
|
29
31
|
subagents?: number;
|
|
32
|
+
/** ③ loop-agent: completed rounds to add to the group's round count. */
|
|
33
|
+
rounds?: number;
|
|
30
34
|
}
|
|
31
35
|
/** A persona session that participated in the logical run (process-table lineage). */
|
|
32
36
|
export interface GroupMember {
|
|
33
37
|
sessionId: string;
|
|
34
38
|
role?: string;
|
|
39
|
+
/** W-N5: what this member IS in the lineage — a `"peer"` persona (ReactiveSession.addPeer) vs a
|
|
40
|
+
* `"vehicle"` session (run()/runWorkflow envelopes, workflow-node children, loop iterations).
|
|
41
|
+
* `ReactiveSession.resume()` rebuilds the peer set from `"peer"` members only, so DAG-in-Peer
|
|
42
|
+
* usage can't resurrect phantom `wf-node*` personas. Absent (legacy) = unknown. */
|
|
43
|
+
kind?: "peer" | "vehicle";
|
|
35
44
|
}
|
|
36
45
|
export interface GroupBudgetStore {
|
|
37
46
|
/** Cumulative spend across the group so far. */
|
|
@@ -3,19 +3,25 @@ export class InMemoryGroupBudgetStore {
|
|
|
3
3
|
ledgers = new Map();
|
|
4
4
|
memberships = new Map();
|
|
5
5
|
read(groupId) {
|
|
6
|
-
return this.ledgers.get(groupId) ?? { tokensSpent: 0, subagentsSpawned: 0 };
|
|
6
|
+
return this.ledgers.get(groupId) ?? { tokensSpent: 0, subagentsSpawned: 0, roundsCompleted: 0 };
|
|
7
7
|
}
|
|
8
8
|
charge(groupId, delta) {
|
|
9
9
|
const cur = this.read(groupId);
|
|
10
10
|
this.ledgers.set(groupId, {
|
|
11
11
|
tokensSpent: cur.tokensSpent + Math.max(0, delta.tokens ?? 0),
|
|
12
12
|
subagentsSpawned: cur.subagentsSpawned + Math.max(0, delta.subagents ?? 0),
|
|
13
|
+
roundsCompleted: (cur.roundsCompleted ?? 0) + Math.max(0, delta.rounds ?? 0),
|
|
13
14
|
});
|
|
14
15
|
}
|
|
15
16
|
join(groupId, member) {
|
|
16
17
|
if (!this.memberships.has(groupId))
|
|
17
18
|
this.memberships.set(groupId, new Map());
|
|
18
|
-
|
|
19
|
+
// First join wins (idempotent by sessionId) — the same contract as SessionLogGroupBudgetStore.
|
|
20
|
+
// A persona registered as "peer" then re-joining through its own run() as "vehicle" must not
|
|
21
|
+
// lose its peer tag (W-N5), and the two stores must agree on which record survives.
|
|
22
|
+
const members = this.memberships.get(groupId);
|
|
23
|
+
if (!members.has(member.sessionId))
|
|
24
|
+
members.set(member.sessionId, member);
|
|
19
25
|
}
|
|
20
26
|
members(groupId) {
|
|
21
27
|
return [...(this.memberships.get(groupId)?.values() ?? [])];
|
|
@@ -34,17 +40,20 @@ export class SessionLogGroupBudgetStore {
|
|
|
34
40
|
async read(groupId) {
|
|
35
41
|
let tokensSpent = 0;
|
|
36
42
|
let subagentsSpawned = 0;
|
|
43
|
+
let roundsCompleted = 0;
|
|
37
44
|
for (const { event } of await this.log.read(groupId)) {
|
|
38
45
|
if (event.kind === "group_budget_charged") {
|
|
46
|
+
roundsCompleted += event.rounds ?? 0;
|
|
39
47
|
tokensSpent += event.tokens;
|
|
40
48
|
subagentsSpawned += event.subagents;
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
|
-
return { tokensSpent, subagentsSpawned };
|
|
51
|
+
return { tokensSpent, subagentsSpawned, roundsCompleted };
|
|
44
52
|
}
|
|
45
53
|
async charge(groupId, delta) {
|
|
46
54
|
await this.log.append(groupId, {
|
|
47
55
|
kind: "group_budget_charged",
|
|
56
|
+
...(delta.rounds !== undefined ? { rounds: delta.rounds } : {}),
|
|
48
57
|
tokens: Math.max(0, delta.tokens ?? 0),
|
|
49
58
|
subagents: Math.max(0, delta.subagents ?? 0),
|
|
50
59
|
});
|
|
@@ -58,13 +67,18 @@ export class SessionLogGroupBudgetStore {
|
|
|
58
67
|
kind: "group_member_joined",
|
|
59
68
|
session_id: member.sessionId,
|
|
60
69
|
...(member.role ? { role: member.role } : {}),
|
|
70
|
+
...(member.kind ? { member_kind: member.kind } : {}),
|
|
61
71
|
});
|
|
62
72
|
}
|
|
63
73
|
async members(groupId) {
|
|
64
74
|
const seen = new Map();
|
|
65
75
|
for (const { event } of await this.log.read(groupId)) {
|
|
66
76
|
if (event.kind === "group_member_joined") {
|
|
67
|
-
seen.set(event.session_id, {
|
|
77
|
+
seen.set(event.session_id, {
|
|
78
|
+
sessionId: event.session_id,
|
|
79
|
+
role: event.role,
|
|
80
|
+
...(event.member_kind ? { kind: event.member_kind } : {}),
|
|
81
|
+
});
|
|
68
82
|
}
|
|
69
83
|
}
|
|
70
84
|
return [...seen.values()];
|
package/dist/runtime/runner.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { LLMProvider, Message, ContentPart, ToolSchema, StreamEvent, ToolSuspendEvent, PermissionRequestEvent, PermissionResponse, AsyncSummarizer, DreamSummarizer } from "../types.js";
|
|
2
2
|
import type { DreamStore, MemoryEntry, MemoryQuery, MemoryWriteRequest } from "../memory/protocols.js";
|
|
3
3
|
import type { KnowledgeSource } from "../knowledge/source.js";
|
|
4
|
-
import type { SignalSource } from "../signals/types.js";
|
|
4
|
+
import type { SignalSource, RuntimeSignalUrgency } from "../signals/types.js";
|
|
5
5
|
import type { SessionLog, SessionEvent } from "./session-log.js";
|
|
6
6
|
import type { ArchiveStore } from "./archive.js";
|
|
7
7
|
import type { ExecutionPlane } from "./execution-plane.js";
|
|
8
8
|
import type { RunGroup } from "./run-group.js";
|
|
9
9
|
import { type MemoryPolicy, type ResourceQuota } from "../kernel.js";
|
|
10
|
+
import { type RecoveredNodeCompletion } from "./session-repair.js";
|
|
10
11
|
import type { AgentRunSpec, MilestoneCheckResult, MilestoneContract, MilestonePolicy, WorkflowSpec } from "../types/agent.js";
|
|
11
12
|
import { type SubAgentOrchestrator } from "./sub-agent-orchestrator.js";
|
|
12
13
|
import { type ReducerRegistry } from "./reducers.js";
|
|
@@ -50,6 +51,18 @@ export interface TurnMetrics {
|
|
|
50
51
|
/** Tokens written to the prompt cache this turn (Anthropic `cache_creation_input_tokens`). */
|
|
51
52
|
cacheCreationTokens: number;
|
|
52
53
|
}
|
|
54
|
+
/** O5: decision returned by `onToolCall` — `block: true` denies this call before it executes; the
|
|
55
|
+
* `reason` is fed back to the model as a governance-denied tool result (so it can redirect). */
|
|
56
|
+
export interface ToolCallHookDecision {
|
|
57
|
+
block?: boolean;
|
|
58
|
+
reason?: string;
|
|
59
|
+
}
|
|
60
|
+
/** O5: decision returned by `onToolResult` — `replaceOutput` swaps the result the model (and the
|
|
61
|
+
* session log) sees; `note` is injected into the signal stream (see `injectNote`). */
|
|
62
|
+
export interface ToolResultHookDecision {
|
|
63
|
+
replaceOutput?: string;
|
|
64
|
+
note?: string;
|
|
65
|
+
}
|
|
53
66
|
export interface RuntimeOptions {
|
|
54
67
|
provider: LLMProvider;
|
|
55
68
|
/** M4/G5: cumulative token cap for this run (the kernel's `max_total_tokens`). A workflow node's
|
|
@@ -73,14 +86,19 @@ export interface RuntimeOptions {
|
|
|
73
86
|
agentId?: string;
|
|
74
87
|
/** I4: optional run-start memory pre-fetch hook. The runner calls this ONCE per run, before the
|
|
75
88
|
* first LLM turn, with the request's goal and (optional) run-spec. Each returned query string
|
|
76
|
-
* becomes a `dreamStore.search(agentId, q, 5)` and the resulting hits
|
|
77
|
-
*
|
|
89
|
+
* becomes a `dreamStore.search(agentId, q, 5)` and the resulting hits land in decaying
|
|
90
|
+
* HISTORY as an ordinary user turn before turn 1 (single-use retrieval content — never a
|
|
91
|
+
* permanent knowledge pin; `initialMemory` is the curated CLAUDE.md-analog seed). Returning
|
|
78
92
|
* `undefined` / empty array is a no-op. Requires `dreamStore` + `agentId`; missing either ⇒
|
|
79
|
-
* silently skipped (errs-open). Bench memory-recall shows -57% turns / -55% dollars when
|
|
93
|
+
* silently skipped (errs-open). Default when unset: one query = the run goal (P10). Bench memory-recall shows -57% turns / -55% dollars when
|
|
80
94
|
* relevant memories land on turn 1 instead of being discovered via the meta-tool on turn 3+. */
|
|
81
95
|
preQueryMemory?: (ctx: {
|
|
82
96
|
goal: string;
|
|
83
97
|
runSpec?: AgentRunSpec;
|
|
98
|
+
/** K4: `"initial"` = the once-per-run pre-turn-1 fetch; `"renewal"` = re-fired after a sprint
|
|
99
|
+
* renewal (renewal drops the old history INCLUDING earlier memory hits, so the new sprint
|
|
100
|
+
* gets a fresh recall pass). Hooks that ignore it keep the pre-K4 behavior. */
|
|
101
|
+
phase?: "initial" | "renewal";
|
|
84
102
|
}) => Promise<string[] | undefined> | string[] | undefined;
|
|
85
103
|
systemPrompt?: string;
|
|
86
104
|
initialMemory?: string[];
|
|
@@ -118,6 +136,63 @@ export interface RuntimeOptions {
|
|
|
118
136
|
* and memory-write syscalls are admitted unconditionally (pre-M2 behavior).
|
|
119
137
|
*/
|
|
120
138
|
resourceQuota?: ResourceQuota;
|
|
139
|
+
/**
|
|
140
|
+
* O6: the in-kernel repeat fuse — the hard rungs above the soft no-progress STOP. When the model
|
|
141
|
+
* re-issues the IDENTICAL tool call (same name AND args) `denyAfter` turns in a row, the kernel
|
|
142
|
+
* denies it and feeds a directive note back; at `terminateAfter` the run ends `no_progress`.
|
|
143
|
+
* Same-tool/different-args loops never trip it. Defaults: enabled, denyAfter 5, terminateAfter 8.
|
|
144
|
+
* Pass `false` to disable (e.g. legit fixed-argument polling loops).
|
|
145
|
+
*/
|
|
146
|
+
repeatFuse?: {
|
|
147
|
+
denyAfter?: number;
|
|
148
|
+
terminateAfter?: number;
|
|
149
|
+
} | false;
|
|
150
|
+
/**
|
|
151
|
+
* O4: the turn-end criteria gate (the Stop-hook analog). When the model tries to finish while the
|
|
152
|
+
* run's `criteria` stand, the kernel injects ONE self-check turn ("verify each criterion; continue
|
|
153
|
+
* if any is unmet") before accepting completion. Fires at most once per run; runs without criteria
|
|
154
|
+
* are untouched. Default enabled — set `false` to accept the first finish unconditionally.
|
|
155
|
+
*/
|
|
156
|
+
criteriaGate?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* K2: max share of `maxTokens` the durable knowledge partition may occupy. Exceeding it emits a
|
|
159
|
+
* `knowledge_budget_exceeded` observation (once per cache generation) and evicts the OLDEST
|
|
160
|
+
* unpinned, non-skill entries at the next compaction/renewal boundary until usage fits. Pinned
|
|
161
|
+
* entries and skill pins are never budget-evicted. `0` disables. Default: kernel's 0.25.
|
|
162
|
+
*/
|
|
163
|
+
knowledgeBudgetRatio?: number;
|
|
164
|
+
/**
|
|
165
|
+
* K3: default lease (in turns) for every skill activation. After that many turns the kernel
|
|
166
|
+
* auto-deactivates the skill — toolset re-widens, knowledge pin boundary-swept — exactly like
|
|
167
|
+
* an explicit `deactivateSkill()`. Absent ⇒ activations are permanent (default). A repeat
|
|
168
|
+
* `skill(name)` call refreshes the lease.
|
|
169
|
+
*/
|
|
170
|
+
skillLeaseTurns?: number;
|
|
171
|
+
/**
|
|
172
|
+
* O5 (the PreToolUse-hook analog): called for each kernel-APPROVED tool call just before it
|
|
173
|
+
* executes. Return `{ block: true, reason }` to veto — the call never runs and the reason is fed
|
|
174
|
+
* back to the model as a denied tool result. This is the seam for STATEFUL host policy (count
|
|
175
|
+
* repeats, budget writes per resource, project-specific rules); keep static allow/deny in
|
|
176
|
+
* `governancePolicy`. Errs-open: a throwing hook never blocks the run.
|
|
177
|
+
*/
|
|
178
|
+
onToolCall?: (call: {
|
|
179
|
+
callId: string;
|
|
180
|
+
name: string;
|
|
181
|
+
arguments: string;
|
|
182
|
+
}) => Promise<ToolCallHookDecision | undefined | void> | ToolCallHookDecision | undefined | void;
|
|
183
|
+
/**
|
|
184
|
+
* O5 (the PostToolUse-hook analog): called for each executed tool result before it reaches the
|
|
185
|
+
* kernel. Return `{ replaceOutput }` to swap the result the model sees (redact / annotate), and/or
|
|
186
|
+
* `{ note }` to push a contextual note into the signal stream (same channel as `injectNote` —
|
|
187
|
+
* e.g. "that write was a no-op, stop repeating it"). Errs-open: a throwing hook changes nothing.
|
|
188
|
+
*/
|
|
189
|
+
onToolResult?: (result: {
|
|
190
|
+
callId: string;
|
|
191
|
+
name: string;
|
|
192
|
+
arguments: string;
|
|
193
|
+
output: string;
|
|
194
|
+
isError: boolean;
|
|
195
|
+
}) => Promise<ToolResultHookDecision | undefined | void> | ToolResultHookDecision | undefined | void;
|
|
121
196
|
/**
|
|
122
197
|
* L1 (RunGroup): bind this runner to a governance domain shared by N peer sessions of one logical
|
|
123
198
|
* run. Members pass the same `id` + `budgetStore`; the kernel's run-level token cap is then enforced
|
|
@@ -217,11 +292,17 @@ export declare class RuntimeRunner {
|
|
|
217
292
|
private activeKernel;
|
|
218
293
|
private pendingObservations;
|
|
219
294
|
private currentSessionId;
|
|
295
|
+
/** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
|
|
296
|
+
private injectedSignals;
|
|
297
|
+
/** Skill names whose content has already been pushed into the durable `knowledge` slot this
|
|
298
|
+
* run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
|
|
299
|
+
* an already-active skill (loading is idempotent; the knowledge push should be too). */
|
|
300
|
+
private knowledgePushedSkills;
|
|
220
301
|
private nextArchiveStart;
|
|
302
|
+
/** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
|
|
303
|
+
private currentGoal;
|
|
221
304
|
/** Full tool outputs keyed by call_id until Layer-1 spool observations are logged. */
|
|
222
305
|
private pendingSpoolOutputs;
|
|
223
|
-
/** Local cache of paged-out/archived messages for priority memory retrieval. */
|
|
224
|
-
private localPageOutCache;
|
|
225
306
|
/** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
|
|
226
307
|
* at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
|
|
227
308
|
private pendingAuthoredWorkflows;
|
|
@@ -256,10 +337,22 @@ export declare class RuntimeRunner {
|
|
|
256
337
|
mountMarker(kind: string, id: string, description: string): void;
|
|
257
338
|
/** Unmount a capability by kind + id from the active run. No-op if not running. */
|
|
258
339
|
unmountCapability(kind: string, id: string): void;
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
340
|
+
/** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts).
|
|
341
|
+
* K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
|
|
342
|
+
* compaction/renewal boundary, where the cached system[1] block is rewritten anyway) instead
|
|
343
|
+
* of appending a duplicate. `opts.pinned` exempts the entry from the knowledge-budget sweep. */
|
|
344
|
+
pushKnowledge(message: Message, tokens?: number, opts?: {
|
|
345
|
+
key?: string;
|
|
346
|
+
pinned?: boolean;
|
|
347
|
+
}): void;
|
|
348
|
+
/** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
|
|
349
|
+
* Errs-open: an unknown key is a kernel-side no-op. */
|
|
350
|
+
removeKnowledge(key: string): void;
|
|
351
|
+
/** K3: host-driven skill deactivation (there is deliberately no model-facing unload — it
|
|
352
|
+
* invites thrash). The toolset re-widens at the next provider call; the skill's knowledge pin
|
|
353
|
+
* drops at the next compaction/renewal boundary. A later `skill(name)` call re-activates and
|
|
354
|
+
* re-pins fresh content. Errs-open: not-active is a kernel-side no-op. */
|
|
355
|
+
deactivateSkill(name: string): void;
|
|
263
356
|
/**
|
|
264
357
|
* Spawn an isolated sub-agent via the kernel, run it on the host, and feed the result back.
|
|
265
358
|
* Requires an active parent run (`run()` / `wake()` in progress or paused at milestone).
|
|
@@ -289,7 +382,15 @@ export declare class RuntimeRunner {
|
|
|
289
382
|
*/
|
|
290
383
|
runWorkflow(spec: WorkflowSpec, opts?: {
|
|
291
384
|
resumedCompleted?: string[];
|
|
385
|
+
/** W-1: recovered completions WITH control signals (classify branch / loop stop) — lowered to
|
|
386
|
+
* the kernel's `resumed_results` so control flow replays faithfully. Supersedes
|
|
387
|
+
* `resumedCompleted` for ids present in both. */
|
|
388
|
+
resumedResults?: RecoveredNodeCompletion[];
|
|
292
389
|
resumedSubmissions?: Record<string, unknown>[][];
|
|
390
|
+
/** R3-1: original base index per submission batch (parallel to resumedSubmissions). */
|
|
391
|
+
resumedSubmissionBases?: number[];
|
|
392
|
+
/** W-1: recovered node outputs (agent id → output text) to pre-seed the driver's outputs map. */
|
|
393
|
+
resumedOutputs?: Map<string, string>;
|
|
293
394
|
/** Standalone session id when bootstrapping (no active parent run). Defaults to a fresh uuid. */
|
|
294
395
|
sessionId?: string;
|
|
295
396
|
}): Promise<{
|
|
@@ -349,8 +450,9 @@ export declare class RuntimeRunner {
|
|
|
349
450
|
private driveWorkflow;
|
|
350
451
|
/**
|
|
351
452
|
* Resume a workflow from the parent session's completed nodes.
|
|
352
|
-
* Reads the session log, extracts completed workflow node
|
|
353
|
-
* calls runWorkflow
|
|
453
|
+
* Reads the session log, extracts completed workflow node records (with their W-1 control
|
|
454
|
+
* signals + outputs), and calls runWorkflow so the kernel skips those nodes, replays control
|
|
455
|
+
* flow (classify prune / loop stop), and the driver re-seeds its outputs map.
|
|
354
456
|
*/
|
|
355
457
|
resumeWorkflow(spec: WorkflowSpec, opts?: {
|
|
356
458
|
sessionId?: string;
|
|
@@ -359,6 +461,16 @@ export declare class RuntimeRunner {
|
|
|
359
461
|
failed: string[];
|
|
360
462
|
}>;
|
|
361
463
|
interrupt(): void;
|
|
464
|
+
/** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
|
|
465
|
+
* the next turn boundary, routes through the kernel attention policy, and — once acted on — renders
|
|
466
|
+
* as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. Use it to feed
|
|
467
|
+
* host-detected events back to the model mid-run (e.g. "that write was a no-op — stop repeating it")
|
|
468
|
+
* without wiring a full `SignalSource`. `urgency` maps to the kernel disposition ladder: `"normal"`
|
|
469
|
+
* queues for the next boundary (default), `"high"` soft-interrupts, `"critical"` preempts. */
|
|
470
|
+
injectNote(text: string, urgency?: RuntimeSignalUrgency): void;
|
|
471
|
+
/** Injected-note drain shared by the main loop's per-turn poll: injected notes first (FIFO), then
|
|
472
|
+
* the configured `signalSource`. Keeps the two inbound channels on one code path so they never drift. */
|
|
473
|
+
private nextInboundSignal;
|
|
362
474
|
run(req: {
|
|
363
475
|
sessionId: string;
|
|
364
476
|
goal: string;
|
|
@@ -376,11 +488,38 @@ export declare class RuntimeRunner {
|
|
|
376
488
|
dream(agentId: string, nowMs?: number): AsyncIterable<StreamEvent>;
|
|
377
489
|
/** Resolve in-kernel AskUser suspend; returns resume lists and stream events to yield. */
|
|
378
490
|
private resolveKernelSuspend;
|
|
491
|
+
/**
|
|
492
|
+
* O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
|
|
493
|
+
* output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map (a call spooled
|
|
494
|
+
* earlier in the SAME tool-turn, before the session-log write lands), (b) the on-disk result
|
|
495
|
+
* spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
|
|
496
|
+
* session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
|
|
497
|
+
* resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
|
|
498
|
+
*/
|
|
499
|
+
private resolveReadResult;
|
|
379
500
|
private execute;
|
|
501
|
+
/** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
|
|
502
|
+
* ordinary user turn — single-use retrieval content that decays with the compression pyramid,
|
|
503
|
+
* never pinned into `knowledge`. Called once before turn 1 (`phase: "initial"`) and re-fired
|
|
504
|
+
* after each sprint renewal (`phase: "renewal"`): renewal drops the old history INCLUDING the
|
|
505
|
+
* earlier memory hits, so the new sprint gets a fresh recall pass. Errs-open throughout. */
|
|
506
|
+
private prefetchMemoryIntoHistory;
|
|
380
507
|
private appendObservations;
|
|
381
508
|
private archiveSemanticPageOut;
|
|
382
509
|
private upgradeCompressedSummary;
|
|
383
510
|
}
|
|
511
|
+
/** Kernel-consumed meta-tools (e.g. `pace`) are answered by a synthetic tool result the kernel keeps
|
|
512
|
+
* in its OWN history but never emits as a `tool_completed` session event (they never reach the
|
|
513
|
+
* execution plane). On replay that leaves an assistant `tool_call` with no following tool result —
|
|
514
|
+
* which strict OpenAI-compatible providers reject ("every tool_call must be answered by a tool
|
|
515
|
+
* message"). This pass re-pairs any such orphan by inserting a synthetic tool-result message right
|
|
516
|
+
* after its assistant message, reproducing the pair the kernel had all along.
|
|
517
|
+
*
|
|
518
|
+
* Discriminator: only pair an orphan when the run **continued past it** — i.e. a later non-tool
|
|
519
|
+
* message exists. A tail assistant tool_call with nothing after it is a genuinely PENDING tool the
|
|
520
|
+
* run stopped in front of (the wake/recovery case), which must stay unpaired so wake executes it.
|
|
521
|
+
* Pure. */
|
|
522
|
+
export declare function pairOrphanToolCalls(messages: Message[]): Message[];
|
|
384
523
|
export declare function replayMessages(events: Array<{
|
|
385
524
|
seq: number;
|
|
386
525
|
event: SessionEvent;
|