@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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/dist/doctor.d.ts +35 -0
  4. package/dist/doctor.js +110 -0
  5. package/dist/errors.d.ts +199 -0
  6. package/dist/errors.js +201 -0
  7. package/dist/events.d.ts +67 -0
  8. package/dist/events.js +40 -0
  9. package/dist/harness.d.ts +51 -0
  10. package/dist/harness.js +70 -0
  11. package/dist/index.d.ts +36 -0
  12. package/dist/index.js +20 -0
  13. package/dist/json.d.ts +15 -0
  14. package/dist/json.js +15 -0
  15. package/dist/modes.d.ts +63 -0
  16. package/dist/modes.js +109 -0
  17. package/dist/runtime.d.ts +104 -0
  18. package/dist/runtime.js +244 -0
  19. package/dist/session.d.ts +114 -0
  20. package/dist/session.js +198 -0
  21. package/dist/skills.d.ts +37 -0
  22. package/dist/skills.js +73 -0
  23. package/dist/store-task-source.d.ts +169 -0
  24. package/dist/store-task-source.js +513 -0
  25. package/dist/stores/file-session-store.d.ts +34 -0
  26. package/dist/stores/file-session-store.js +284 -0
  27. package/dist/stores/memory-session-store.d.ts +22 -0
  28. package/dist/stores/memory-session-store.js +97 -0
  29. package/dist/stores/session-store.d.ts +65 -0
  30. package/dist/stores/session-store.js +3 -0
  31. package/dist/stores/store-manager.d.ts +32 -0
  32. package/dist/stores/store-manager.js +57 -0
  33. package/dist/subagent-runner.d.ts +45 -0
  34. package/dist/subagent-runner.js +89 -0
  35. package/dist/subagents.d.ts +182 -0
  36. package/dist/subagents.js +286 -0
  37. package/dist/task-tools.d.ts +55 -0
  38. package/dist/task-tools.js +136 -0
  39. package/dist/tasks.d.ts +257 -0
  40. package/dist/tasks.js +162 -0
  41. package/dist/thread.d.ts +49 -0
  42. package/dist/thread.js +88 -0
  43. package/dist/tools.d.ts +89 -0
  44. package/dist/tools.js +148 -0
  45. 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;