@particle-academy/prism-harness 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/doctor.d.ts +35 -0
- package/dist/doctor.js +110 -0
- package/dist/errors.d.ts +199 -0
- package/dist/errors.js +201 -0
- package/dist/events.d.ts +67 -0
- package/dist/events.js +40 -0
- package/dist/harness.d.ts +51 -0
- package/dist/harness.js +70 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +20 -0
- package/dist/json.d.ts +15 -0
- package/dist/json.js +15 -0
- package/dist/modes.d.ts +63 -0
- package/dist/modes.js +109 -0
- package/dist/runtime.d.ts +104 -0
- package/dist/runtime.js +244 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +198 -0
- package/dist/skills.d.ts +37 -0
- package/dist/skills.js +73 -0
- package/dist/store-task-source.d.ts +169 -0
- package/dist/store-task-source.js +513 -0
- package/dist/stores/file-session-store.d.ts +34 -0
- package/dist/stores/file-session-store.js +284 -0
- package/dist/stores/memory-session-store.d.ts +22 -0
- package/dist/stores/memory-session-store.js +97 -0
- package/dist/stores/session-store.d.ts +65 -0
- package/dist/stores/session-store.js +3 -0
- package/dist/stores/store-manager.d.ts +32 -0
- package/dist/stores/store-manager.js +57 -0
- package/dist/subagent-runner.d.ts +45 -0
- package/dist/subagent-runner.js +89 -0
- package/dist/subagents.d.ts +182 -0
- package/dist/subagents.js +286 -0
- package/dist/task-tools.d.ts +55 -0
- package/dist/task-tools.js +136 -0
- package/dist/tasks.d.ts +257 -0
- package/dist/tasks.js +162 -0
- package/dist/thread.d.ts +49 -0
- package/dist/thread.js +88 -0
- package/dist/tools.d.ts +89 -0
- package/dist/tools.js +148 -0
- package/package.json +34 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { MAX_DEPTH } from './subagents.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns a declared `Subagent` into a TOOL the parent run can call.
|
|
4
|
+
*
|
|
5
|
+
* The parent is mid-run and holding its own session lock when this executes.
|
|
6
|
+
* Everything here is arranged so that fact stays harmless:
|
|
7
|
+
*
|
|
8
|
+
* - the child resolves its OWN session address, so it takes a different lock;
|
|
9
|
+
* - the child's authority comes from its declared mode, never the parent's;
|
|
10
|
+
* - the child's budget is drawn from the tree's remaining allowance;
|
|
11
|
+
* - EVERY way the child can end returns a framed result rather than throwing,
|
|
12
|
+
* because the parent is a legitimate audience for "that did not work" and
|
|
13
|
+
* tearing down the parent run would discard work it had already done.
|
|
14
|
+
*/
|
|
15
|
+
export class SubagentRunner {
|
|
16
|
+
harness;
|
|
17
|
+
runtime;
|
|
18
|
+
constructor(harness, runtime) {
|
|
19
|
+
this.harness = harness;
|
|
20
|
+
this.runtime = runtime;
|
|
21
|
+
}
|
|
22
|
+
tool(subagent, parent, context, parentRunId) {
|
|
23
|
+
return {
|
|
24
|
+
name: subagent.name,
|
|
25
|
+
description: subagent.description,
|
|
26
|
+
handle: async (args) => {
|
|
27
|
+
const task = typeof args.task === 'string' ? args.task : '';
|
|
28
|
+
return (await this.run(subagent, parent, context, parentRunId, task)).toToolResult();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async run(subagent, parent, context, parentRunId, task) {
|
|
33
|
+
const runId = `run_${Math.random().toString(16).slice(2, 14)}`;
|
|
34
|
+
// Checked BEFORE spawning, so an exhausted tree does not pay for a session,
|
|
35
|
+
// a thread write and a provider call to discover it is exhausted.
|
|
36
|
+
const stop = context.ledger.exhaustion(context.budget);
|
|
37
|
+
if (stop !== null) {
|
|
38
|
+
return refused(subagent.name, runId, parentRunId, context.ledger.cancelled ? 'cancelled' : 'exhausted', stop);
|
|
39
|
+
}
|
|
40
|
+
const childContext = context.forChild(subagent, parentRunId);
|
|
41
|
+
// Refused BEFORE the child's address is built. Two modes naming each other
|
|
42
|
+
// as subagents form a cycle budgets would eventually stop — but only after
|
|
43
|
+
// each level had appended `::sub::<name>` to an address that may truncate
|
|
44
|
+
// rather than error, and two children truncated to the same string are one
|
|
45
|
+
// conversation.
|
|
46
|
+
if (childContext.tooDeep()) {
|
|
47
|
+
return refused(subagent.name, runId, parentRunId, 'denied', `subagent nesting reached the maximum depth of ${MAX_DEPTH}`);
|
|
48
|
+
}
|
|
49
|
+
const child = this.harness
|
|
50
|
+
.for(parent.participant)
|
|
51
|
+
.session(subagent.scopeUnder(parent.scope));
|
|
52
|
+
// The child's authority comes from ITS mode, not the parent's.
|
|
53
|
+
await child.usingMode(subagent.mode);
|
|
54
|
+
await child.usingProvider((await parent.provider()) ?? 'unknown');
|
|
55
|
+
await child.usingModel((await parent.model()) ?? 'unknown');
|
|
56
|
+
try {
|
|
57
|
+
const response = await this.runtime.send(child, task, undefined, childContext);
|
|
58
|
+
return {
|
|
59
|
+
subagent: subagent.name,
|
|
60
|
+
runId: response.runId,
|
|
61
|
+
parentRunId,
|
|
62
|
+
outcome: response.stoppedBecause === null ? 'completed' : 'exhausted',
|
|
63
|
+
text: response.text,
|
|
64
|
+
toToolResult() {
|
|
65
|
+
return response.stoppedBecause === null
|
|
66
|
+
? response.text
|
|
67
|
+
: `The [${subagent.name}] subagent stopped early: ${response.stoppedBecause}. Partial answer: ${response.text}`;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// Framed, not thrown. Tearing down the parent run would discard work it
|
|
73
|
+
// has already done, and "that subagent failed" is something the parent
|
|
74
|
+
// can act on.
|
|
75
|
+
return refused(subagent.name, runId, parentRunId, 'failed', error instanceof Error ? error.message : String(error));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function refused(subagent, runId, parentRunId, outcome, reason) {
|
|
80
|
+
const text = `The [${subagent}] subagent did not run: ${reason}.`;
|
|
81
|
+
return {
|
|
82
|
+
subagent,
|
|
83
|
+
runId,
|
|
84
|
+
parentRunId,
|
|
85
|
+
outcome,
|
|
86
|
+
text,
|
|
87
|
+
toToolResult: () => text,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How deep a run tree may nest.
|
|
3
|
+
*
|
|
4
|
+
* Budgets alone do bound a cycle — mode A calling B calling A terminates when
|
|
5
|
+
* the steps run out — but they do not bound it CHEAPLY, and they do not bound
|
|
6
|
+
* the child's address, which grows by `::sub::<name>` at every level. A tree
|
|
7
|
+
* deep enough would produce session keys long enough to truncate in a store
|
|
8
|
+
* with a fixed-width key and collide two distinct children onto one
|
|
9
|
+
* conversation.
|
|
10
|
+
*
|
|
11
|
+
* Depth is also the honest limit to state: nobody debugs a six-deep agent tree,
|
|
12
|
+
* and a config that produced one is a mistake worth reporting rather than
|
|
13
|
+
* executing.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MAX_DEPTH = 4;
|
|
16
|
+
/**
|
|
17
|
+
* What a run is ALLOWED TO SPEND.
|
|
18
|
+
*
|
|
19
|
+
* `maxSteps` alone was never a budget. It bounds ITERATIONS, and twenty steps
|
|
20
|
+
* each calling an expensive tool sits comfortably inside it — so a run could
|
|
21
|
+
* respect its declared limit and still cost more than anyone intended. Cost and
|
|
22
|
+
* wall-clock are the two a person actually cares about when they say "bounded".
|
|
23
|
+
*/
|
|
24
|
+
export declare class RunBudget {
|
|
25
|
+
readonly maxSteps: number;
|
|
26
|
+
readonly maxCostUsd: number | null;
|
|
27
|
+
readonly maxSeconds: number | null;
|
|
28
|
+
constructor(maxSteps: number, maxCostUsd?: number | null, maxSeconds?: number | null);
|
|
29
|
+
static fromConfig(config: Record<string, unknown>, defaultSteps?: number): RunBudget;
|
|
30
|
+
/**
|
|
31
|
+
* The budget a CHILD actually gets, as a TREE-ABSOLUTE ceiling.
|
|
32
|
+
*
|
|
33
|
+
* BUDGETS NEST; THEY DO NOT RESET. A resetting budget is not a budget: a
|
|
34
|
+
* parent limited to 8 steps that may spawn subagents each entitled to a fresh
|
|
35
|
+
* 8 has no bound at all — it has a bound per node in a tree whose width it
|
|
36
|
+
* also controls, which is unbounded spend wearing a limit's clothing.
|
|
37
|
+
*
|
|
38
|
+
* ## Absolute, not remaining — and this is a FIX, not a port
|
|
39
|
+
*
|
|
40
|
+
* The reference computes the child's budget as what REMAINS
|
|
41
|
+
* (`min(declared, parent.maxSteps - ledger.steps)`) and then `exhaustion()`
|
|
42
|
+
* compares the ledger's CUMULATIVE steps against it. Those two are in
|
|
43
|
+
* different units, and the result is that a child is refused the moment its
|
|
44
|
+
* parent has spent anything: parent 8, ledger 7, child declares 2 gives a
|
|
45
|
+
* budget of 1, and `7 >= 1` is immediately exhausted — so the child gets
|
|
46
|
+
* ZERO steps while the tree genuinely has one left. Verified against the
|
|
47
|
+
* reference's own arithmetic, not inferred.
|
|
48
|
+
*
|
|
49
|
+
* Expressing the child's budget as a ceiling THE TREE MAY REACH makes both
|
|
50
|
+
* halves the same unit and gives the right answer in every case: an unspent
|
|
51
|
+
* parent still hands a child exactly what it declared, and a nearly-spent one
|
|
52
|
+
* hands it exactly what is left.
|
|
53
|
+
*
|
|
54
|
+
* Recorded in the envelope's port gaps register as a divergence the reference
|
|
55
|
+
* should adopt.
|
|
56
|
+
*/
|
|
57
|
+
nestedWithin(parent: RunBudget, ledger: RunLedger): RunBudget;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What a run TREE has actually spent, and whether it has been cancelled.
|
|
61
|
+
*
|
|
62
|
+
* SHARED BY REFERENCE from a parent to every descendant, which is the whole
|
|
63
|
+
* point: budgets nest, and nesting is only real if the child's spend lands in
|
|
64
|
+
* the same account the parent is measured against. A per-run ledger would let
|
|
65
|
+
* each node report itself within budget while the tree went far past it.
|
|
66
|
+
*
|
|
67
|
+
* Mutable on purpose, and the only mutable thing here. Spend is a running
|
|
68
|
+
* total; modelling it immutably would mean threading a new instance back up
|
|
69
|
+
* through every return, and the one place that must not be missed is the
|
|
70
|
+
* failure path.
|
|
71
|
+
*/
|
|
72
|
+
export declare class RunLedger {
|
|
73
|
+
#private;
|
|
74
|
+
readonly rootRunId: string;
|
|
75
|
+
private readonly startedAt;
|
|
76
|
+
constructor(rootRunId: string, startedAt?: number);
|
|
77
|
+
static start(rootRunId: string): RunLedger;
|
|
78
|
+
get steps(): number;
|
|
79
|
+
get costUsd(): number;
|
|
80
|
+
get unmeteredRuns(): number;
|
|
81
|
+
elapsedSeconds(): number;
|
|
82
|
+
recordSteps(steps: number): void;
|
|
83
|
+
/**
|
|
84
|
+
* Charge a run's cost to the tree.
|
|
85
|
+
*
|
|
86
|
+
* NULL IS NOT ZERO. A provider's reported cost is nullable because not every
|
|
87
|
+
* provider reports one, and folding that into `+= 0` would leave a cost
|
|
88
|
+
* budget that can never trip — enforced in the documentation, absent at
|
|
89
|
+
* runtime, and indistinguishable from a tree that genuinely spent nothing.
|
|
90
|
+
* Counted separately so `exhaustion()` can say the cap is UNENFORCEABLE
|
|
91
|
+
* instead of quietly failing open.
|
|
92
|
+
*/
|
|
93
|
+
recordCost(usd: number | null): void;
|
|
94
|
+
/**
|
|
95
|
+
* Stop the tree.
|
|
96
|
+
*
|
|
97
|
+
* COOPERATIVE rather than pre-emptive: a running tool cannot be interrupted,
|
|
98
|
+
* and pretending otherwise would be the more dangerous lie. A half-executed
|
|
99
|
+
* tool is precisely the state the durability layer exists to protect, so the
|
|
100
|
+
* in-flight call is allowed to finish and the NEXT step is refused.
|
|
101
|
+
*/
|
|
102
|
+
cancel(reason?: string): void;
|
|
103
|
+
get cancelled(): boolean;
|
|
104
|
+
get cancelReason(): string | null;
|
|
105
|
+
remainingCost(budget: RunBudget): number | null;
|
|
106
|
+
remainingSeconds(budget: RunBudget): number | null;
|
|
107
|
+
/**
|
|
108
|
+
* Why the tree may not spend again — or null when it may.
|
|
109
|
+
*
|
|
110
|
+
* Returns a REASON rather than a boolean. The states are genuinely different
|
|
111
|
+
* (cancelled / out of steps / out of money / out of time) and a caller that
|
|
112
|
+
* cannot tell them apart writes one message for four causes, which is the
|
|
113
|
+
* collapse this ecosystem keeps finding. See prism-parity decision 0020.
|
|
114
|
+
*/
|
|
115
|
+
exhaustion(budget: RunBudget): string | null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A nested agent a parent run may call, and the AUTHORITY IT GETS.
|
|
119
|
+
*
|
|
120
|
+
* The authority is DECLARED rather than inherited. A subagent that ran with
|
|
121
|
+
* whatever its parent happened to hold would make "narrowed toolset" a
|
|
122
|
+
* description instead of a constraint — and the narrowing is the entire reason
|
|
123
|
+
* to reach for a subagent rather than another turn of the parent.
|
|
124
|
+
*/
|
|
125
|
+
export declare class Subagent {
|
|
126
|
+
readonly name: string;
|
|
127
|
+
readonly description: string;
|
|
128
|
+
readonly mode: string;
|
|
129
|
+
readonly budget: RunBudget;
|
|
130
|
+
/**
|
|
131
|
+
* The scope suffix the child's own session and thread live under.
|
|
132
|
+
*
|
|
133
|
+
* Deterministic, so a cold worker resuming the tree lands on the same child
|
|
134
|
+
* conversation instead of starting a fresh one. Defaults to the name.
|
|
135
|
+
*/
|
|
136
|
+
readonly scopeSuffix: string | null;
|
|
137
|
+
constructor(name: string, description: string, mode: string, budget: RunBudget,
|
|
138
|
+
/**
|
|
139
|
+
* The scope suffix the child's own session and thread live under.
|
|
140
|
+
*
|
|
141
|
+
* Deterministic, so a cold worker resuming the tree lands on the same child
|
|
142
|
+
* conversation instead of starting a fresh one. Defaults to the name.
|
|
143
|
+
*/
|
|
144
|
+
scopeSuffix?: string | null);
|
|
145
|
+
/**
|
|
146
|
+
* The scope the child session resolves under.
|
|
147
|
+
*
|
|
148
|
+
* A DIFFERENT scope from the parent, which is what keeps this from
|
|
149
|
+
* deadlocking. A session's lock is taken on its address, and a nested run
|
|
150
|
+
* asking for the parent's address inside the parent's own lock would wait for
|
|
151
|
+
* a lock it is already holding. Giving the child its own address removes the
|
|
152
|
+
* contention rather than making the lock reentrant — which would let a child
|
|
153
|
+
* mutate parent state mid-run, the precise thing the lock is for.
|
|
154
|
+
*/
|
|
155
|
+
scopeUnder(parentScope: string): string;
|
|
156
|
+
}
|
|
157
|
+
export declare function subagentFromConfig(name: string, config: Record<string, unknown>): Subagent;
|
|
158
|
+
/**
|
|
159
|
+
* Where a run sits in its tree, and what the tree has left to spend.
|
|
160
|
+
*
|
|
161
|
+
* A run with NO context is a root: that is the ordinary case and stays free of
|
|
162
|
+
* all of this.
|
|
163
|
+
*/
|
|
164
|
+
export declare class RunContext {
|
|
165
|
+
readonly ledger: RunLedger;
|
|
166
|
+
readonly budget: RunBudget;
|
|
167
|
+
readonly parentRunId: string | null;
|
|
168
|
+
readonly depth: number;
|
|
169
|
+
constructor(ledger: RunLedger, budget: RunBudget, parentRunId?: string | null, depth?: number);
|
|
170
|
+
static root(runId: string, budget: RunBudget): RunContext;
|
|
171
|
+
get rootRunId(): string;
|
|
172
|
+
isChild(): boolean;
|
|
173
|
+
/**
|
|
174
|
+
* The context a child inherits: SAME LEDGER, narrowed budget.
|
|
175
|
+
*
|
|
176
|
+
* Same ledger by reference is the load-bearing part. A child with its own
|
|
177
|
+
* ledger would let every node report itself inside budget while the tree
|
|
178
|
+
* spent without limit.
|
|
179
|
+
*/
|
|
180
|
+
forChild(subagent: Subagent, parentRunId: string): RunContext;
|
|
181
|
+
tooDeep(): boolean;
|
|
182
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How deep a run tree may nest.
|
|
3
|
+
*
|
|
4
|
+
* Budgets alone do bound a cycle — mode A calling B calling A terminates when
|
|
5
|
+
* the steps run out — but they do not bound it CHEAPLY, and they do not bound
|
|
6
|
+
* the child's address, which grows by `::sub::<name>` at every level. A tree
|
|
7
|
+
* deep enough would produce session keys long enough to truncate in a store
|
|
8
|
+
* with a fixed-width key and collide two distinct children onto one
|
|
9
|
+
* conversation.
|
|
10
|
+
*
|
|
11
|
+
* Depth is also the honest limit to state: nobody debugs a six-deep agent tree,
|
|
12
|
+
* and a config that produced one is a mistake worth reporting rather than
|
|
13
|
+
* executing.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_DEPTH = 4;
|
|
16
|
+
/**
|
|
17
|
+
* What a run is ALLOWED TO SPEND.
|
|
18
|
+
*
|
|
19
|
+
* `maxSteps` alone was never a budget. It bounds ITERATIONS, and twenty steps
|
|
20
|
+
* each calling an expensive tool sits comfortably inside it — so a run could
|
|
21
|
+
* respect its declared limit and still cost more than anyone intended. Cost and
|
|
22
|
+
* wall-clock are the two a person actually cares about when they say "bounded".
|
|
23
|
+
*/
|
|
24
|
+
export class RunBudget {
|
|
25
|
+
maxSteps;
|
|
26
|
+
maxCostUsd;
|
|
27
|
+
maxSeconds;
|
|
28
|
+
constructor(maxSteps, maxCostUsd = null, maxSeconds = null) {
|
|
29
|
+
this.maxSteps = maxSteps;
|
|
30
|
+
this.maxCostUsd = maxCostUsd;
|
|
31
|
+
this.maxSeconds = maxSeconds;
|
|
32
|
+
}
|
|
33
|
+
static fromConfig(config, defaultSteps = 8) {
|
|
34
|
+
return new RunBudget(numberOr(config.max_steps, defaultSteps), numberOrNull(config.max_cost_usd), numberOrNull(config.max_seconds));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The budget a CHILD actually gets, as a TREE-ABSOLUTE ceiling.
|
|
38
|
+
*
|
|
39
|
+
* BUDGETS NEST; THEY DO NOT RESET. A resetting budget is not a budget: a
|
|
40
|
+
* parent limited to 8 steps that may spawn subagents each entitled to a fresh
|
|
41
|
+
* 8 has no bound at all — it has a bound per node in a tree whose width it
|
|
42
|
+
* also controls, which is unbounded spend wearing a limit's clothing.
|
|
43
|
+
*
|
|
44
|
+
* ## Absolute, not remaining — and this is a FIX, not a port
|
|
45
|
+
*
|
|
46
|
+
* The reference computes the child's budget as what REMAINS
|
|
47
|
+
* (`min(declared, parent.maxSteps - ledger.steps)`) and then `exhaustion()`
|
|
48
|
+
* compares the ledger's CUMULATIVE steps against it. Those two are in
|
|
49
|
+
* different units, and the result is that a child is refused the moment its
|
|
50
|
+
* parent has spent anything: parent 8, ledger 7, child declares 2 gives a
|
|
51
|
+
* budget of 1, and `7 >= 1` is immediately exhausted — so the child gets
|
|
52
|
+
* ZERO steps while the tree genuinely has one left. Verified against the
|
|
53
|
+
* reference's own arithmetic, not inferred.
|
|
54
|
+
*
|
|
55
|
+
* Expressing the child's budget as a ceiling THE TREE MAY REACH makes both
|
|
56
|
+
* halves the same unit and gives the right answer in every case: an unspent
|
|
57
|
+
* parent still hands a child exactly what it declared, and a nearly-spent one
|
|
58
|
+
* hands it exactly what is left.
|
|
59
|
+
*
|
|
60
|
+
* Recorded in the envelope's port gaps register as a divergence the reference
|
|
61
|
+
* should adopt.
|
|
62
|
+
*/
|
|
63
|
+
nestedWithin(parent, ledger) {
|
|
64
|
+
return new RunBudget(Math.min(parent.maxSteps, ledger.steps + this.maxSteps), lesser(parent.maxCostUsd, this.maxCostUsd === null ? null : ledger.costUsd + this.maxCostUsd), lesser(parent.maxSeconds, this.maxSeconds === null ? null : Math.trunc(ledger.elapsedSeconds()) + this.maxSeconds));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* What a run TREE has actually spent, and whether it has been cancelled.
|
|
69
|
+
*
|
|
70
|
+
* SHARED BY REFERENCE from a parent to every descendant, which is the whole
|
|
71
|
+
* point: budgets nest, and nesting is only real if the child's spend lands in
|
|
72
|
+
* the same account the parent is measured against. A per-run ledger would let
|
|
73
|
+
* each node report itself within budget while the tree went far past it.
|
|
74
|
+
*
|
|
75
|
+
* Mutable on purpose, and the only mutable thing here. Spend is a running
|
|
76
|
+
* total; modelling it immutably would mean threading a new instance back up
|
|
77
|
+
* through every return, and the one place that must not be missed is the
|
|
78
|
+
* failure path.
|
|
79
|
+
*/
|
|
80
|
+
export class RunLedger {
|
|
81
|
+
rootRunId;
|
|
82
|
+
startedAt;
|
|
83
|
+
#steps = 0;
|
|
84
|
+
#costUsd = 0;
|
|
85
|
+
#unmeteredRuns = 0;
|
|
86
|
+
#cancelled = false;
|
|
87
|
+
#cancelReason = null;
|
|
88
|
+
constructor(rootRunId, startedAt = Date.now()) {
|
|
89
|
+
this.rootRunId = rootRunId;
|
|
90
|
+
this.startedAt = startedAt;
|
|
91
|
+
}
|
|
92
|
+
static start(rootRunId) {
|
|
93
|
+
return new RunLedger(rootRunId);
|
|
94
|
+
}
|
|
95
|
+
get steps() {
|
|
96
|
+
return this.#steps;
|
|
97
|
+
}
|
|
98
|
+
get costUsd() {
|
|
99
|
+
return this.#costUsd;
|
|
100
|
+
}
|
|
101
|
+
get unmeteredRuns() {
|
|
102
|
+
return this.#unmeteredRuns;
|
|
103
|
+
}
|
|
104
|
+
elapsedSeconds() {
|
|
105
|
+
return (Date.now() - this.startedAt) / 1000;
|
|
106
|
+
}
|
|
107
|
+
recordSteps(steps) {
|
|
108
|
+
this.#steps += Math.max(0, steps);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Charge a run's cost to the tree.
|
|
112
|
+
*
|
|
113
|
+
* NULL IS NOT ZERO. A provider's reported cost is nullable because not every
|
|
114
|
+
* provider reports one, and folding that into `+= 0` would leave a cost
|
|
115
|
+
* budget that can never trip — enforced in the documentation, absent at
|
|
116
|
+
* runtime, and indistinguishable from a tree that genuinely spent nothing.
|
|
117
|
+
* Counted separately so `exhaustion()` can say the cap is UNENFORCEABLE
|
|
118
|
+
* instead of quietly failing open.
|
|
119
|
+
*/
|
|
120
|
+
recordCost(usd) {
|
|
121
|
+
if (usd === null) {
|
|
122
|
+
this.#unmeteredRuns += 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.#costUsd += Math.max(0, usd);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Stop the tree.
|
|
129
|
+
*
|
|
130
|
+
* COOPERATIVE rather than pre-emptive: a running tool cannot be interrupted,
|
|
131
|
+
* and pretending otherwise would be the more dangerous lie. A half-executed
|
|
132
|
+
* tool is precisely the state the durability layer exists to protect, so the
|
|
133
|
+
* in-flight call is allowed to finish and the NEXT step is refused.
|
|
134
|
+
*/
|
|
135
|
+
cancel(reason = 'cancelled') {
|
|
136
|
+
this.#cancelled = true;
|
|
137
|
+
this.#cancelReason = reason;
|
|
138
|
+
}
|
|
139
|
+
get cancelled() {
|
|
140
|
+
return this.#cancelled;
|
|
141
|
+
}
|
|
142
|
+
get cancelReason() {
|
|
143
|
+
return this.#cancelReason;
|
|
144
|
+
}
|
|
145
|
+
remainingCost(budget) {
|
|
146
|
+
return budget.maxCostUsd === null ? null : Math.max(0, budget.maxCostUsd - this.#costUsd);
|
|
147
|
+
}
|
|
148
|
+
remainingSeconds(budget) {
|
|
149
|
+
return budget.maxSeconds === null
|
|
150
|
+
? null
|
|
151
|
+
: Math.max(0, Math.trunc(budget.maxSeconds - this.elapsedSeconds()));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Why the tree may not spend again — or null when it may.
|
|
155
|
+
*
|
|
156
|
+
* Returns a REASON rather than a boolean. The states are genuinely different
|
|
157
|
+
* (cancelled / out of steps / out of money / out of time) and a caller that
|
|
158
|
+
* cannot tell them apart writes one message for four causes, which is the
|
|
159
|
+
* collapse this ecosystem keeps finding. See prism-parity decision 0020.
|
|
160
|
+
*/
|
|
161
|
+
exhaustion(budget) {
|
|
162
|
+
if (this.#cancelled) {
|
|
163
|
+
return this.#cancelReason ?? 'cancelled';
|
|
164
|
+
}
|
|
165
|
+
if (this.#steps >= budget.maxSteps) {
|
|
166
|
+
return `step budget exhausted (${this.#steps} of ${budget.maxSteps} used)`;
|
|
167
|
+
}
|
|
168
|
+
if (budget.maxCostUsd !== null && this.#unmeteredRuns > 0) {
|
|
169
|
+
// Failing CLOSED. A cost cap the provider gives us no numbers to enforce
|
|
170
|
+
// is not a cap, and continuing would spend without limit under a budget
|
|
171
|
+
// the operator believes is holding.
|
|
172
|
+
return (`cost budget cannot be enforced: ${this.#unmeteredRuns} run(s) reported no cost, ` +
|
|
173
|
+
`so spend against the ${budget.maxCostUsd.toFixed(4)} USD cap is unknown`);
|
|
174
|
+
}
|
|
175
|
+
if (budget.maxCostUsd !== null && this.#costUsd >= budget.maxCostUsd) {
|
|
176
|
+
return `cost budget exhausted (${this.#costUsd.toFixed(4)} of ${budget.maxCostUsd.toFixed(4)} USD used)`;
|
|
177
|
+
}
|
|
178
|
+
if (budget.maxSeconds !== null && this.elapsedSeconds() >= budget.maxSeconds) {
|
|
179
|
+
return `time budget exhausted (${Math.trunc(this.elapsedSeconds())}s of ${budget.maxSeconds}s used)`;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A nested agent a parent run may call, and the AUTHORITY IT GETS.
|
|
186
|
+
*
|
|
187
|
+
* The authority is DECLARED rather than inherited. A subagent that ran with
|
|
188
|
+
* whatever its parent happened to hold would make "narrowed toolset" a
|
|
189
|
+
* description instead of a constraint — and the narrowing is the entire reason
|
|
190
|
+
* to reach for a subagent rather than another turn of the parent.
|
|
191
|
+
*/
|
|
192
|
+
export class Subagent {
|
|
193
|
+
name;
|
|
194
|
+
description;
|
|
195
|
+
mode;
|
|
196
|
+
budget;
|
|
197
|
+
scopeSuffix;
|
|
198
|
+
constructor(name, description, mode, budget,
|
|
199
|
+
/**
|
|
200
|
+
* The scope suffix the child's own session and thread live under.
|
|
201
|
+
*
|
|
202
|
+
* Deterministic, so a cold worker resuming the tree lands on the same child
|
|
203
|
+
* conversation instead of starting a fresh one. Defaults to the name.
|
|
204
|
+
*/
|
|
205
|
+
scopeSuffix = null) {
|
|
206
|
+
this.name = name;
|
|
207
|
+
this.description = description;
|
|
208
|
+
this.mode = mode;
|
|
209
|
+
this.budget = budget;
|
|
210
|
+
this.scopeSuffix = scopeSuffix;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The scope the child session resolves under.
|
|
214
|
+
*
|
|
215
|
+
* A DIFFERENT scope from the parent, which is what keeps this from
|
|
216
|
+
* deadlocking. A session's lock is taken on its address, and a nested run
|
|
217
|
+
* asking for the parent's address inside the parent's own lock would wait for
|
|
218
|
+
* a lock it is already holding. Giving the child its own address removes the
|
|
219
|
+
* contention rather than making the lock reentrant — which would let a child
|
|
220
|
+
* mutate parent state mid-run, the precise thing the lock is for.
|
|
221
|
+
*/
|
|
222
|
+
scopeUnder(parentScope) {
|
|
223
|
+
return `${parentScope}::sub::${this.scopeSuffix ?? this.name}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
export function subagentFromConfig(name, config) {
|
|
227
|
+
const description = config.description;
|
|
228
|
+
const mode = config.mode;
|
|
229
|
+
const scope = config.scope;
|
|
230
|
+
return new Subagent(name, typeof description === 'string' && description !== ''
|
|
231
|
+
? description
|
|
232
|
+
: `Run the [${name}] subagent and return its result.`, typeof mode === 'string' && mode !== '' ? mode : name, RunBudget.fromConfig(config), typeof scope === 'string' ? scope : null);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Where a run sits in its tree, and what the tree has left to spend.
|
|
236
|
+
*
|
|
237
|
+
* A run with NO context is a root: that is the ordinary case and stays free of
|
|
238
|
+
* all of this.
|
|
239
|
+
*/
|
|
240
|
+
export class RunContext {
|
|
241
|
+
ledger;
|
|
242
|
+
budget;
|
|
243
|
+
parentRunId;
|
|
244
|
+
depth;
|
|
245
|
+
constructor(ledger, budget, parentRunId = null, depth = 0) {
|
|
246
|
+
this.ledger = ledger;
|
|
247
|
+
this.budget = budget;
|
|
248
|
+
this.parentRunId = parentRunId;
|
|
249
|
+
this.depth = depth;
|
|
250
|
+
}
|
|
251
|
+
static root(runId, budget) {
|
|
252
|
+
return new RunContext(RunLedger.start(runId), budget);
|
|
253
|
+
}
|
|
254
|
+
get rootRunId() {
|
|
255
|
+
return this.ledger.rootRunId;
|
|
256
|
+
}
|
|
257
|
+
isChild() {
|
|
258
|
+
return this.parentRunId !== null;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The context a child inherits: SAME LEDGER, narrowed budget.
|
|
262
|
+
*
|
|
263
|
+
* Same ledger by reference is the load-bearing part. A child with its own
|
|
264
|
+
* ledger would let every node report itself inside budget while the tree
|
|
265
|
+
* spent without limit.
|
|
266
|
+
*/
|
|
267
|
+
forChild(subagent, parentRunId) {
|
|
268
|
+
return new RunContext(this.ledger, subagent.budget.nestedWithin(this.budget, this.ledger), parentRunId, this.depth + 1);
|
|
269
|
+
}
|
|
270
|
+
tooDeep() {
|
|
271
|
+
return this.depth >= MAX_DEPTH;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function lesser(a, b) {
|
|
275
|
+
if (a === null)
|
|
276
|
+
return b;
|
|
277
|
+
if (b === null)
|
|
278
|
+
return a;
|
|
279
|
+
return Math.min(a, b);
|
|
280
|
+
}
|
|
281
|
+
function numberOr(value, fallback) {
|
|
282
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
|
|
283
|
+
}
|
|
284
|
+
function numberOrNull(value) {
|
|
285
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
286
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ToolAuthorizer, ToolFactory } from './tools.js';
|
|
2
|
+
import type { AgentTask, AgentTaskSource } from './tasks.js';
|
|
3
|
+
export interface CompletionToolOptions {
|
|
4
|
+
/** Where the task lives. */
|
|
5
|
+
source: AgentTaskSource;
|
|
6
|
+
/** The task this tool may close. ONE task, the one the worker is holding. */
|
|
7
|
+
task: AgentTask;
|
|
8
|
+
/**
|
|
9
|
+
* The worker holding the task. REQUIRED, and load-bearing.
|
|
10
|
+
*
|
|
11
|
+
* A completion tool that names only a source can close any task in the list,
|
|
12
|
+
* including one another worker is midway through -- and a tool the model can
|
|
13
|
+
* call is the last place to leave that reachable. Passing the worker through
|
|
14
|
+
* means the source refuses anything this worker is not actually holding.
|
|
15
|
+
*/
|
|
16
|
+
worker: string;
|
|
17
|
+
/** Must be ENABLED. See below for why that is checked rather than assumed. */
|
|
18
|
+
authorizer: ToolAuthorizer;
|
|
19
|
+
/** Defaults to `complete_task`. */
|
|
20
|
+
name?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A tool that lets the AGENT close its own task. OFF UNLESS A CONSUMER BUILDS IT.
|
|
24
|
+
*
|
|
25
|
+
* ## Why this is not the default
|
|
26
|
+
*
|
|
27
|
+
* If the model can set its own task to `done`, then "run until the goal is met"
|
|
28
|
+
* silently becomes "run until it decides it is met", and a run that has stalled
|
|
29
|
+
* ends by declaring victory. That is the same failure `prism-human-plus`
|
|
30
|
+
* addresses by reserving confirmation for the human, and the same reason the
|
|
31
|
+
* default here is off in all three languages. A port that ships it on has
|
|
32
|
+
* changed an observable decision, not a default.
|
|
33
|
+
*
|
|
34
|
+
* By default `release()` is called by the APPLICATION, from evidence. Nothing
|
|
35
|
+
* in this package registers this tool, and a `StoreTaskSource` on its own gives
|
|
36
|
+
* an agent no route to a terminal state.
|
|
37
|
+
*
|
|
38
|
+
* ## Why the authorizer is checked
|
|
39
|
+
*
|
|
40
|
+
* Through the EXISTING `ToolAuthorizer` -- no new permission mechanism, because
|
|
41
|
+
* a second mechanism is a second place to grant something by accident.
|
|
42
|
+
*
|
|
43
|
+
* A disabled authorizer is refused rather than tolerated. `ToolAuthorizer`
|
|
44
|
+
* defaults to off, and off means every registered tool is offered to every run
|
|
45
|
+
* and every call is allowed; a consumer who wires this tool up against one has
|
|
46
|
+
* granted the agent completion authority while believing a control is in place.
|
|
47
|
+
* Same argument, and the same error code, as a policy that is defined and never
|
|
48
|
+
* consulted.
|
|
49
|
+
*
|
|
50
|
+
* The returned factory produces a tool wrapped by `authorizedTool`, so the call
|
|
51
|
+
* policy is asked again AT CALL TIME with the arguments -- an offer-time check
|
|
52
|
+
* alone can say "may complete tasks" and never "only this task, only once the
|
|
53
|
+
* tests pass".
|
|
54
|
+
*/
|
|
55
|
+
export declare function agentCompletionTool(options: CompletionToolOptions): ToolFactory;
|