@cruxy/cli 0.29.0 → 0.29.2

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.
@@ -0,0 +1,52 @@
1
+ import type { Usage } from "@cruxy/sdk";
2
+ import type { LoopBudget } from "./loop.js";
3
+ /**
4
+ * The live budget primitive for the agent loop: iteration + token + optional
5
+ * wall-clock caps, checked by {@link runAgent} before every model turn (see
6
+ * `LoopBudget`). A tripped cap stops the run with a human-readable reason and
7
+ * the coherent partial history — the loop never runs unbounded.
8
+ *
9
+ * It lives beside the loop it guards: the loop defines the `LoopBudget` seam,
10
+ * this is its one concrete implementation, and the subagent orchestrator, the
11
+ * job manager, and {@link Session} (per-turn token guard) all consume it.
12
+ */
13
+ /**
14
+ * Hard caps a run executes under. `maxTokens` is always finite; `maxIterations`
15
+ * is finite for a subagent (bounded by construction) but may be
16
+ * `Number.POSITIVE_INFINITY` for a token-only guard where iteration count is
17
+ * bounded elsewhere (a main turn is already capped by `agent.maxIterations` in
18
+ * the loop). `timeoutMs` is an optional wall-clock backstop on top.
19
+ */
20
+ export interface BudgetLimits {
21
+ /** Cap on the run's model turns (may be `Infinity` for a token-only guard). */
22
+ maxIterations: number;
23
+ /** Cap on the run's combined input+output tokens. */
24
+ maxTokens: number;
25
+ /** Optional wall-clock cap in milliseconds. */
26
+ timeoutMs?: number;
27
+ }
28
+ /**
29
+ * Resolve the effective limits for one spawn: start from the configured
30
+ * ceilings and let overrides only *narrow* them. A request above a ceiling is
31
+ * clamped down, not honored — "budget overrides within limits" by construction.
32
+ */
33
+ export declare function resolveBudget(defaults: BudgetLimits, overrides?: Partial<BudgetLimits>): BudgetLimits;
34
+ /**
35
+ * A live budget for one run. The wall clock starts at construction (spawn /
36
+ * turn start); the clock source is injectable so tests never sleep.
37
+ */
38
+ export declare class Budget implements LoopBudget {
39
+ private readonly limits;
40
+ private readonly now;
41
+ private readonly startedAt;
42
+ constructor(limits: BudgetLimits, now?: () => number);
43
+ /**
44
+ * The reason to stop before the next model turn, or `null` to continue.
45
+ * Checked at iteration boundaries — the in-flight turn always completes, so
46
+ * overshoot is bounded by one turn.
47
+ */
48
+ exceeded(state: {
49
+ iterations: number;
50
+ usage: Usage;
51
+ }): string | null;
52
+ }
@@ -1,9 +1,3 @@
1
- /**
2
- * The subagent budget (C.14): iteration + token + optional wall-clock caps,
3
- * checked by the agent loop before every model turn (see `LoopBudget`). A
4
- * tripped cap stops the run with a human-readable reason — the subagent
5
- * returns a partial result, it never runs unbounded.
6
- */
7
1
  /**
8
2
  * Resolve the effective limits for one spawn: start from the configured
9
3
  * ceilings and let overrides only *narrow* them. A request above a ceiling is
@@ -23,8 +17,8 @@ export function resolveBudget(defaults, overrides) {
23
17
  };
24
18
  }
25
19
  /**
26
- * A live budget for one subagent run. The wall clock starts at construction
27
- * (spawn time); the clock source is injectable so tests never sleep.
20
+ * A live budget for one run. The wall clock starts at construction (spawn /
21
+ * turn start); the clock source is injectable so tests never sleep.
28
22
  */
29
23
  export class Budget {
30
24
  limits;
@@ -103,6 +103,21 @@ export interface RunAgentArgs {
103
103
  * is kill-tree'd rather than orphaned.
104
104
  */
105
105
  signal?: AbortSignal;
106
+ /**
107
+ * Mid-loop compaction seam (build item 3). Called at the top of every
108
+ * iteration with the running history; returns the history to continue from —
109
+ * unchanged when under threshold, or with its older prefix summarized away
110
+ * when over. Supplied by {@link Session}, which reuses its own
111
+ * threshold/cut/summarize machinery, so the loop gains no summarization
112
+ * knowledge and no logic is duplicated. Omitted → history is never compacted
113
+ * mid-loop (unchanged behavior; subagents pass nothing).
114
+ *
115
+ * This is what keeps a long autonomous turn — where `send` compacts only once
116
+ * up front and never again — within the context window: without it a run that
117
+ * drives dozens of tool calls (each result appended, some tens of KB) grows
118
+ * unbounded until the next `send`, which in one-shot never comes.
119
+ */
120
+ compact?: (messages: Message[]) => Promise<Message[]>;
106
121
  }
107
122
  /**
108
123
  * The budget seam for {@link runAgent}: implementations track their own caps
@@ -40,8 +40,9 @@ async function driveLoop(args, renderer, routed) {
40
40
  const { provider, registry, config, ctx } = args;
41
41
  const { logger } = ctx;
42
42
  // Work on a copy so we never mutate the caller's array as a side effect; the
43
- // extended history is returned for the caller to adopt.
44
- const messages = [...args.messages];
43
+ // extended history is returned for the caller to adopt. Reassigned wholesale
44
+ // when the mid-loop compaction seam folds away an older prefix.
45
+ let messages = [...args.messages];
45
46
  const usage = { input_tokens: 0, output_tokens: 0 };
46
47
  const maxIterations = config.agent.maxIterations;
47
48
  // The tool catalogue and environment are stable across the loop, so build the
@@ -88,6 +89,14 @@ async function driveLoop(args, renderer, routed) {
88
89
  usage,
89
90
  };
90
91
  }
92
+ // Mid-loop compaction (build item 3), after the abort/budget checks so a
93
+ // cancelled or over-budget run never spends a summary call: fold away the
94
+ // older prefix when the history has grown past threshold *this* iteration.
95
+ // The seam preserves tool_use/tool_result integrity (it only cuts at a
96
+ // completed turn boundary), so adopting its result mid-turn is safe.
97
+ if (args.compact) {
98
+ messages = await args.compact(messages);
99
+ }
91
100
  iterations = i + 1;
92
101
  const tools = registry.toToolSpecs();
93
102
  // ── Consume one model turn ──────────────────────────────────────────────
@@ -145,23 +145,46 @@ export declare class Session {
145
145
  /** Drop the conversation history but keep the session (for `/clear`). */
146
146
  clear(): void;
147
147
  /**
148
- * Compact only when the estimated history exceeds
149
- * `compactThreshold * maxTokens`. On success logs a one-line notice and
150
- * returns the number of older messages folded into the summary; otherwise
151
- * returns `null` (under threshold, nothing safe to cut, or summary failed).
148
+ * Compact `this.messages` only when it has grown past threshold, adopting the
149
+ * result. On success logs a one-line notice and returns the number of older
150
+ * messages folded into the summary; otherwise returns `null` (under threshold,
151
+ * nothing safe to cut, or summary failed).
152
152
  */
153
153
  maybeCompact(onRequestUsage?: (req: RequestUsage) => void): Promise<number | null>;
154
+ /**
155
+ * The mid-loop compaction seam (build item 3) handed to {@link runAgent}: same
156
+ * threshold/cut/summarize path as {@link maybeCompact}, but over the loop's own
157
+ * running history rather than `this.messages` (the loop owns and adopts its
158
+ * copy). Reuses the exact machinery so no summarization logic is duplicated and
159
+ * the loop stays ignorant of it. Under threshold it returns the history
160
+ * unchanged — cheap, no model call.
161
+ */
162
+ private compactLoopHistory;
154
163
  /**
155
164
  * Force compaction regardless of the threshold (backs `/compact`). Returns the
156
165
  * number of older messages summarized, or `null` if there was nothing safe to
157
166
  * cut or the summary call failed.
158
167
  */
159
168
  compact(): Promise<number | null>;
169
+ /**
170
+ * Compact `messages` when its estimated footprint exceeds
171
+ * `compactThreshold * maxTokens`, else return it untouched. The estimate adds a
172
+ * fixed `reserveTokens` allowance for the system prompt and tool schemas that
173
+ * {@link estimateTokens} never sees (~4.5k+ tokens of real request payload), so
174
+ * the trigger reflects the actual request size rather than only the visible
175
+ * history — otherwise the loop can sit just under the visible threshold while
176
+ * the real request has already overrun the window. Logs a one-line notice on a
177
+ * successful compaction.
178
+ */
179
+ private compactIfOverThreshold;
160
180
  /**
161
181
  * Find a clean cut, summarize the older prefix into a synthetic user/assistant
162
182
  * pair, and splice it in front of the kept-recent messages. Best-effort: a
163
- * failed summary call leaves the history untouched and returns `null` (fail
164
- * open — losing compaction is degraded, not unsafe).
183
+ * failed summary call leaves the history untouched and reports `compacted:
184
+ * null` (fail open — losing compaction is degraded, not unsafe). Pure with
185
+ * respect to `this.messages`: it returns the new array for the caller to adopt
186
+ * (the loop and the session each own their own history), and only accumulates
187
+ * the summary's usage into the session total.
165
188
  */
166
189
  private runCompaction;
167
190
  /**
@@ -171,7 +194,9 @@ export declare class Session {
171
194
  * matching `tool_result` (the next user message) must never straddle the cut,
172
195
  * or the next provider call breaks. A real user *prompt* (`role:"user"` with
173
196
  * string content) only occurs at a completed turn boundary, where every prior
174
- * tool exchange is already resolved — so the kept region must begin there.
197
+ * tool exchange is already resolved — so the kept region must begin there. The
198
+ * synthetic compaction-summary user message is also string content, so a
199
+ * repeat compaction always finds at least the previous summary as a clean cut.
175
200
  *
176
201
  * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
177
202
  * such prompt: this keeps at least the recent floor and lands clean. Returns
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { loadProjectInstructions } from "../config/index.js";
3
3
  import { resolveTaskModel } from "../routing/index.js";
4
4
  import { UsageCollector, } from "../usage/index.js";
5
+ import { Budget } from "./budget.js";
5
6
  import { runAgent, } from "./loop.js";
6
7
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
7
8
  /**
@@ -115,6 +116,20 @@ export class Session {
115
116
  const onReq = (req) => collector.record(req);
116
117
  // Compact *before* the agent call so the turn runs against a bounded history.
117
118
  await this.maybeCompact(onReq);
119
+ // Per-turn runaway guard (build item 4): when `agent.maxTokensPerTurn` is
120
+ // set, hand the loop a fresh token-only budget for THIS turn. Reusing the
121
+ // Budget primitive with an infinite iteration cap makes it purely a token
122
+ // stop — the loop's own `agent.maxIterations` already bounds turn count.
123
+ // Unset/0 → no budget object at all, so the loop's budget check is a no-op
124
+ // and behavior is byte-identical to before. Rebuilt each `send`, so the cap
125
+ // is per-turn, never cumulative across the session (cost is C.22's concern).
126
+ const maxTokensPerTurn = this.args.config.agent.maxTokensPerTurn;
127
+ const budget = maxTokensPerTurn > 0
128
+ ? new Budget({
129
+ maxIterations: Number.POSITIVE_INFINITY,
130
+ maxTokens: maxTokensPerTurn,
131
+ })
132
+ : undefined;
118
133
  // before-run (C.19): a blocking pre-run hook — or an untrusted project's
119
134
  // hooks — throws here and aborts the turn before the model is engaged
120
135
  // (fail-closed). No-op when hooks are disabled or none are registered.
@@ -133,12 +148,21 @@ export class Session {
133
148
  : await runAgent({
134
149
  messages: this.messages,
135
150
  ...this.args, // carries `router` through to the loop
151
+ // The per-turn token guard (build item 4). After the spread because
152
+ // SessionArgs has no `budget` field — it is a per-turn construction,
153
+ // not session state. `undefined` when the cap is off (no-op check).
154
+ budget,
136
155
  taskClass: "main-turn",
137
156
  // After the spread so a mid-session `/reload` wins over the initial value.
138
157
  projectInstructions: this.projectInstructions,
139
158
  planMode: false, // the plan directive belongs only to the runner's propose phase
140
159
  renderer,
141
160
  onRequestUsage: onReq,
161
+ // Mid-loop compaction (build item 3): let a long autonomous turn
162
+ // compact between iterations, not just once up front. Reuses this
163
+ // session's threshold/cut/summarize path over the loop's own history
164
+ // and attributes the summary usage to this run's collector.
165
+ compact: (messages) => this.compactLoopHistory(messages, onReq),
142
166
  });
143
167
  this.messages = result.messages;
144
168
  this.usage.input_tokens += result.usage.input_tokens;
@@ -169,21 +193,27 @@ export class Session {
169
193
  this.messages = [];
170
194
  }
171
195
  /**
172
- * Compact only when the estimated history exceeds
173
- * `compactThreshold * maxTokens`. On success logs a one-line notice and
174
- * returns the number of older messages folded into the summary; otherwise
175
- * returns `null` (under threshold, nothing safe to cut, or summary failed).
196
+ * Compact `this.messages` only when it has grown past threshold, adopting the
197
+ * result. On success logs a one-line notice and returns the number of older
198
+ * messages folded into the summary; otherwise returns `null` (under threshold,
199
+ * nothing safe to cut, or summary failed).
176
200
  */
177
201
  async maybeCompact(onRequestUsage) {
178
- const { maxTokens, compactThreshold } = this.args.config.context;
179
- if (estimateTokens(this.messages) <= compactThreshold * maxTokens) {
180
- return null;
181
- }
182
- const n = await this.runCompaction(onRequestUsage);
183
- if (n) {
184
- this.args.ctx.logger.info(`compacted ${n} older message${n === 1 ? "" : "s"} to stay within context`);
185
- }
186
- return n;
202
+ const { messages, compacted } = await this.compactIfOverThreshold(this.messages, onRequestUsage);
203
+ this.messages = messages;
204
+ return compacted;
205
+ }
206
+ /**
207
+ * The mid-loop compaction seam (build item 3) handed to {@link runAgent}: same
208
+ * threshold/cut/summarize path as {@link maybeCompact}, but over the loop's own
209
+ * running history rather than `this.messages` (the loop owns and adopts its
210
+ * copy). Reuses the exact machinery so no summarization logic is duplicated and
211
+ * the loop stays ignorant of it. Under threshold it returns the history
212
+ * unchanged — cheap, no model call.
213
+ */
214
+ async compactLoopHistory(messages, onRequestUsage) {
215
+ const result = await this.compactIfOverThreshold(messages, onRequestUsage);
216
+ return result.messages;
187
217
  }
188
218
  /**
189
219
  * Force compaction regardless of the threshold (backs `/compact`). Returns the
@@ -191,20 +221,47 @@ export class Session {
191
221
  * cut or the summary call failed.
192
222
  */
193
223
  async compact() {
194
- return this.runCompaction();
224
+ const { messages, compacted } = await this.runCompaction(this.messages);
225
+ this.messages = messages;
226
+ return compacted;
227
+ }
228
+ /**
229
+ * Compact `messages` when its estimated footprint exceeds
230
+ * `compactThreshold * maxTokens`, else return it untouched. The estimate adds a
231
+ * fixed `reserveTokens` allowance for the system prompt and tool schemas that
232
+ * {@link estimateTokens} never sees (~4.5k+ tokens of real request payload), so
233
+ * the trigger reflects the actual request size rather than only the visible
234
+ * history — otherwise the loop can sit just under the visible threshold while
235
+ * the real request has already overrun the window. Logs a one-line notice on a
236
+ * successful compaction.
237
+ */
238
+ async compactIfOverThreshold(messages, onRequestUsage) {
239
+ const { maxTokens, compactThreshold, reserveTokens } = this.args.config.context;
240
+ const estimated = estimateTokens(messages) + reserveTokens;
241
+ if (estimated <= compactThreshold * maxTokens) {
242
+ return { messages, compacted: null };
243
+ }
244
+ const result = await this.runCompaction(messages, onRequestUsage);
245
+ if (result.compacted) {
246
+ this.args.ctx.logger.info(`compacted ${result.compacted} older message${result.compacted === 1 ? "" : "s"} to stay within context`);
247
+ }
248
+ return result;
195
249
  }
196
250
  /**
197
251
  * Find a clean cut, summarize the older prefix into a synthetic user/assistant
198
252
  * pair, and splice it in front of the kept-recent messages. Best-effort: a
199
- * failed summary call leaves the history untouched and returns `null` (fail
200
- * open — losing compaction is degraded, not unsafe).
253
+ * failed summary call leaves the history untouched and reports `compacted:
254
+ * null` (fail open — losing compaction is degraded, not unsafe). Pure with
255
+ * respect to `this.messages`: it returns the new array for the caller to adopt
256
+ * (the loop and the session each own their own history), and only accumulates
257
+ * the summary's usage into the session total.
201
258
  */
202
- async runCompaction(onRequestUsage) {
203
- const cut = this.findCut();
259
+ async runCompaction(messages, onRequestUsage) {
260
+ const cut = this.findCut(messages);
204
261
  if (cut === null)
205
- return null;
206
- const prefix = this.messages.slice(0, cut);
207
- const kept = this.messages.slice(cut);
262
+ return { messages, compacted: null };
263
+ const prefix = messages.slice(0, cut);
264
+ const kept = messages.slice(cut);
208
265
  let synopsis;
209
266
  try {
210
267
  const summary = await this.summarize(prefix, onRequestUsage);
@@ -214,7 +271,7 @@ export class Session {
214
271
  }
215
272
  catch (err) {
216
273
  this.args.ctx.logger.warn(`compaction skipped: summary failed (${err.message})`);
217
- return null;
274
+ return { messages, compacted: null };
218
275
  }
219
276
  // A user/assistant pair, not a lone message: the kept region begins with a
220
277
  // user prompt, so a single synthetic user message would put two user turns
@@ -229,8 +286,10 @@ export class Session {
229
286
  content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
230
287
  },
231
288
  ];
232
- this.messages = [...summaryMessages, ...kept];
233
- return prefix.length;
289
+ return {
290
+ messages: [...summaryMessages, ...kept],
291
+ compacted: prefix.length,
292
+ };
234
293
  }
235
294
  /**
236
295
  * Choose the boundary between the summarized prefix and the kept-recent tail.
@@ -239,18 +298,20 @@ export class Session {
239
298
  * matching `tool_result` (the next user message) must never straddle the cut,
240
299
  * or the next provider call breaks. A real user *prompt* (`role:"user"` with
241
300
  * string content) only occurs at a completed turn boundary, where every prior
242
- * tool exchange is already resolved — so the kept region must begin there.
301
+ * tool exchange is already resolved — so the kept region must begin there. The
302
+ * synthetic compaction-summary user message is also string content, so a
303
+ * repeat compaction always finds at least the previous summary as a clean cut.
243
304
  *
244
305
  * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
245
306
  * such prompt: this keeps at least the recent floor and lands clean. Returns
246
307
  * the cut index, or `null` if no safe boundary leaves a non-empty prefix
247
308
  * (e.g. a single long in-progress turn — nothing safe to compact).
248
309
  */
249
- findCut() {
310
+ findCut(messages) {
250
311
  const { keepRecentMessages } = this.args.config.context;
251
- const start = this.messages.length - keepRecentMessages;
312
+ const start = messages.length - keepRecentMessages;
252
313
  for (let i = start; i >= 1; i--) {
253
- const msg = this.messages[i];
314
+ const msg = messages[i];
254
315
  if (msg.role === "user" && typeof msg.content === "string")
255
316
  return i;
256
317
  }
@@ -41,6 +41,19 @@ export declare const AgentConfigSchema: z.ZodObject<{
41
41
  * `maxIterations` — a user may deliberately set it lower or higher.
42
42
  */
43
43
  maxIterationsOneShot: z.ZodDefault<z.ZodNumber>;
44
+ /**
45
+ * Optional hard cap on combined input+output tokens for a SINGLE user turn
46
+ * — a last-resort runaway guard, not cost control (cumulative session cost
47
+ * is C.22's concern). OFF by default (0 = off): today's behavior is
48
+ * preserved exactly and no turn is ever token-stopped. When set, a turn that
49
+ * crosses the cap stops at the next turn boundary with a coherent partial
50
+ * history (`stop: "budget"`); in one-shot that fails loud (exit 20), while
51
+ * interactively the human simply continues. Compaction is the everyday
52
+ * pressure valve for context — this only bounds a genuinely runaway turn, so
53
+ * a hostile hard stop mid-work stays opt-in. Reset each `send`, never
54
+ * cumulative across turns.
55
+ */
56
+ maxTokensPerTurn: z.ZodDefault<z.ZodNumber>;
44
57
  /** Skip per-action confirmation prompts. */
45
58
  autoApprove: z.ZodDefault<z.ZodBoolean>;
46
59
  /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
@@ -48,11 +61,13 @@ export declare const AgentConfigSchema: z.ZodObject<{
48
61
  }, "strict", z.ZodTypeAny, {
49
62
  maxIterations: number;
50
63
  maxIterationsOneShot: number;
64
+ maxTokensPerTurn: number;
51
65
  autoApprove: boolean;
52
66
  planMode: boolean;
53
67
  }, {
54
68
  maxIterations?: number | undefined;
55
69
  maxIterationsOneShot?: number | undefined;
70
+ maxTokensPerTurn?: number | undefined;
56
71
  autoApprove?: boolean | undefined;
57
72
  planMode?: boolean | undefined;
58
73
  }>;
@@ -103,16 +118,26 @@ export declare const ContextConfigSchema: z.ZodObject<{
103
118
  maxTokens: z.ZodDefault<z.ZodNumber>;
104
119
  /** Compact once the history estimate exceeds this fraction of maxTokens. */
105
120
  compactThreshold: z.ZodDefault<z.ZodNumber>;
121
+ /**
122
+ * Fixed token allowance for request payload that `estimateTokens` never
123
+ * sees — the system prompt and every tool's JSON schema — added to the
124
+ * measured history before the threshold test so the trigger reflects the
125
+ * real request size, not just the visible messages. Roughly the size of
126
+ * the built system prompt plus the default tool catalogue today.
127
+ */
128
+ reserveTokens: z.ZodDefault<z.ZodNumber>;
106
129
  /** Most-recent messages always kept verbatim (a floor; the cut rounds up to
107
130
  * a clean turn boundary). */
108
131
  keepRecentMessages: z.ZodDefault<z.ZodNumber>;
109
132
  }, "strict", z.ZodTypeAny, {
110
133
  maxTokens: number;
111
134
  compactThreshold: number;
135
+ reserveTokens: number;
112
136
  keepRecentMessages: number;
113
137
  }, {
114
138
  maxTokens?: number | undefined;
115
139
  compactThreshold?: number | undefined;
140
+ reserveTokens?: number | undefined;
116
141
  keepRecentMessages?: number | undefined;
117
142
  }>;
118
143
  /**
@@ -1009,6 +1034,19 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1009
1034
  * `maxIterations` — a user may deliberately set it lower or higher.
1010
1035
  */
1011
1036
  maxIterationsOneShot: z.ZodDefault<z.ZodNumber>;
1037
+ /**
1038
+ * Optional hard cap on combined input+output tokens for a SINGLE user turn
1039
+ * — a last-resort runaway guard, not cost control (cumulative session cost
1040
+ * is C.22's concern). OFF by default (0 = off): today's behavior is
1041
+ * preserved exactly and no turn is ever token-stopped. When set, a turn that
1042
+ * crosses the cap stops at the next turn boundary with a coherent partial
1043
+ * history (`stop: "budget"`); in one-shot that fails loud (exit 20), while
1044
+ * interactively the human simply continues. Compaction is the everyday
1045
+ * pressure valve for context — this only bounds a genuinely runaway turn, so
1046
+ * a hostile hard stop mid-work stays opt-in. Reset each `send`, never
1047
+ * cumulative across turns.
1048
+ */
1049
+ maxTokensPerTurn: z.ZodDefault<z.ZodNumber>;
1012
1050
  /** Skip per-action confirmation prompts. */
1013
1051
  autoApprove: z.ZodDefault<z.ZodBoolean>;
1014
1052
  /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
@@ -1016,11 +1054,13 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1016
1054
  }, "strict", z.ZodTypeAny, {
1017
1055
  maxIterations: number;
1018
1056
  maxIterationsOneShot: number;
1057
+ maxTokensPerTurn: number;
1019
1058
  autoApprove: boolean;
1020
1059
  planMode: boolean;
1021
1060
  }, {
1022
1061
  maxIterations?: number | undefined;
1023
1062
  maxIterationsOneShot?: number | undefined;
1063
+ maxTokensPerTurn?: number | undefined;
1024
1064
  autoApprove?: boolean | undefined;
1025
1065
  planMode?: boolean | undefined;
1026
1066
  }>>;
@@ -1068,16 +1108,26 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1068
1108
  maxTokens: z.ZodDefault<z.ZodNumber>;
1069
1109
  /** Compact once the history estimate exceeds this fraction of maxTokens. */
1070
1110
  compactThreshold: z.ZodDefault<z.ZodNumber>;
1111
+ /**
1112
+ * Fixed token allowance for request payload that `estimateTokens` never
1113
+ * sees — the system prompt and every tool's JSON schema — added to the
1114
+ * measured history before the threshold test so the trigger reflects the
1115
+ * real request size, not just the visible messages. Roughly the size of
1116
+ * the built system prompt plus the default tool catalogue today.
1117
+ */
1118
+ reserveTokens: z.ZodDefault<z.ZodNumber>;
1071
1119
  /** Most-recent messages always kept verbatim (a floor; the cut rounds up to
1072
1120
  * a clean turn boundary). */
1073
1121
  keepRecentMessages: z.ZodDefault<z.ZodNumber>;
1074
1122
  }, "strict", z.ZodTypeAny, {
1075
1123
  maxTokens: number;
1076
1124
  compactThreshold: number;
1125
+ reserveTokens: number;
1077
1126
  keepRecentMessages: number;
1078
1127
  }, {
1079
1128
  maxTokens?: number | undefined;
1080
1129
  compactThreshold?: number | undefined;
1130
+ reserveTokens?: number | undefined;
1081
1131
  keepRecentMessages?: number | undefined;
1082
1132
  }>>;
1083
1133
  approval: z.ZodDefault<z.ZodObject<{
@@ -1768,6 +1818,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1768
1818
  agent: {
1769
1819
  maxIterations: number;
1770
1820
  maxIterationsOneShot: number;
1821
+ maxTokensPerTurn: number;
1771
1822
  autoApprove: boolean;
1772
1823
  planMode: boolean;
1773
1824
  };
@@ -1783,6 +1834,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1783
1834
  context: {
1784
1835
  maxTokens: number;
1785
1836
  compactThreshold: number;
1837
+ reserveTokens: number;
1786
1838
  keepRecentMessages: number;
1787
1839
  };
1788
1840
  index: {
@@ -1921,6 +1973,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1921
1973
  agent?: {
1922
1974
  maxIterations?: number | undefined;
1923
1975
  maxIterationsOneShot?: number | undefined;
1976
+ maxTokensPerTurn?: number | undefined;
1924
1977
  autoApprove?: boolean | undefined;
1925
1978
  planMode?: boolean | undefined;
1926
1979
  } | undefined;
@@ -1936,6 +1989,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1936
1989
  context?: {
1937
1990
  maxTokens?: number | undefined;
1938
1991
  compactThreshold?: number | undefined;
1992
+ reserveTokens?: number | undefined;
1939
1993
  keepRecentMessages?: number | undefined;
1940
1994
  } | undefined;
1941
1995
  index?: {
@@ -41,6 +41,19 @@ export const AgentConfigSchema = z
41
41
  * `maxIterations` — a user may deliberately set it lower or higher.
42
42
  */
43
43
  maxIterationsOneShot: z.number().int().positive().default(40),
44
+ /**
45
+ * Optional hard cap on combined input+output tokens for a SINGLE user turn
46
+ * — a last-resort runaway guard, not cost control (cumulative session cost
47
+ * is C.22's concern). OFF by default (0 = off): today's behavior is
48
+ * preserved exactly and no turn is ever token-stopped. When set, a turn that
49
+ * crosses the cap stops at the next turn boundary with a coherent partial
50
+ * history (`stop: "budget"`); in one-shot that fails loud (exit 20), while
51
+ * interactively the human simply continues. Compaction is the everyday
52
+ * pressure valve for context — this only bounds a genuinely runaway turn, so
53
+ * a hostile hard stop mid-work stays opt-in. Reset each `send`, never
54
+ * cumulative across turns.
55
+ */
56
+ maxTokensPerTurn: z.number().int().nonnegative().default(0),
44
57
  /** Skip per-action confirmation prompts. */
45
58
  autoApprove: z.boolean().default(false),
46
59
  /** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
@@ -81,6 +94,14 @@ export const ContextConfigSchema = z
81
94
  maxTokens: z.number().int().positive().default(100000),
82
95
  /** Compact once the history estimate exceeds this fraction of maxTokens. */
83
96
  compactThreshold: z.number().min(0).max(1).default(0.75),
97
+ /**
98
+ * Fixed token allowance for request payload that `estimateTokens` never
99
+ * sees — the system prompt and every tool's JSON schema — added to the
100
+ * measured history before the threshold test so the trigger reflects the
101
+ * real request size, not just the visible messages. Roughly the size of
102
+ * the built system prompt plus the default tool catalogue today.
103
+ */
104
+ reserveTokens: z.number().int().nonnegative().default(4500),
84
105
  /** Most-recent messages always kept verbatim (a floor; the cut rounds up to
85
106
  * a clean turn boundary). */
86
107
  keepRecentMessages: z.number().int().positive().default(6),
@@ -2,7 +2,8 @@ import { runAgent } from "../agent/loop.js";
2
2
  import { ApprovalService, classify, serializeGate, } from "../approval/index.js";
3
3
  import { CheckpointGate, withCheckpointGate } from "../checkpoint/index.js";
4
4
  import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, } from "../errors/index.js";
5
- import { Budget, resolveBudget, scopeRegistry, } from "../subagent/index.js";
5
+ import { Budget, resolveBudget } from "../agent/budget.js";
6
+ import { scopeRegistry } from "../subagent/index.js";
6
7
  import { Workspace } from "../workspace/index.js";
7
8
  import { LogBuffer } from "./log-buffer.js";
8
9
  import { JobLogRenderer } from "./log-renderer.js";
@@ -1,5 +1,4 @@
1
1
  export * from "./types.js";
2
- export * from "./budget.js";
3
2
  export * from "./semaphore.js";
4
3
  export * from "./registry-scope.js";
5
4
  export * from "./orchestrator.js";
@@ -1,5 +1,4 @@
1
1
  export * from "./types.js";
2
- export * from "./budget.js";
3
2
  export * from "./semaphore.js";
4
3
  export * from "./registry-scope.js";
5
4
  export * from "./orchestrator.js";
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { runAgent } from "../agent/loop.js";
3
3
  import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
4
4
  import { Workspace } from "../workspace/index.js";
5
- import { Budget, resolveBudget } from "./budget.js";
5
+ import { Budget, resolveBudget } from "../agent/budget.js";
6
6
  import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
7
7
  import { Semaphore } from "./semaphore.js";
8
8
  import { makeSpawnSubagentTool } from "./spawn-tool.js";
@@ -1,5 +1,7 @@
1
1
  import type { Usage } from "@cruxy/sdk";
2
2
  import type { TaskClass } from "../routing/index.js";
3
+ import type { BudgetLimits } from "../agent/budget.js";
4
+ export type { BudgetLimits };
3
5
  /**
4
6
  * Types for subagent orchestration (C.14): the main agent delegates a bounded
5
7
  * subtask to a child agent that runs the SAME loop with its own fresh history,
@@ -14,19 +16,6 @@ import type { TaskClass } from "../routing/index.js";
14
16
  * dressed up as `done`.
15
17
  */
16
18
  export type SubagentStatus = "done" | "budget-exceeded" | "failed" | "cancelled";
17
- /**
18
- * Hard caps a subagent runs under. `maxIterations` and `maxTokens` are always
19
- * finite — a subagent is bounded by construction; `timeoutMs` is an optional
20
- * wall-clock backstop on top.
21
- */
22
- export interface BudgetLimits {
23
- /** Cap on the subagent's model turns. */
24
- maxIterations: number;
25
- /** Cap on the subagent's combined input+output tokens. */
26
- maxTokens: number;
27
- /** Optional wall-clock cap in milliseconds. */
28
- timeoutMs?: number;
29
- }
30
19
  /** A spawn request: the bounded task plus optional scope/budget narrowing. */
31
20
  export interface SubagentSpec {
32
21
  /** The complete, self-contained subtask the subagent should perform. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.29.0",
3
+ "version": "0.29.2",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "undici": "^6.21.0",
38
38
  "zod": "^3.23.8",
39
39
  "zod-to-json-schema": "^3.23.5",
40
- "@cruxy/sdk": "0.2.0"
40
+ "@cruxy/sdk": "0.2.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/better-sqlite3": "^7.6.13",
@@ -1,34 +0,0 @@
1
- import type { Usage } from "@cruxy/sdk";
2
- import type { LoopBudget } from "../agent/loop.js";
3
- import type { BudgetLimits } from "./types.js";
4
- /**
5
- * The subagent budget (C.14): iteration + token + optional wall-clock caps,
6
- * checked by the agent loop before every model turn (see `LoopBudget`). A
7
- * tripped cap stops the run with a human-readable reason — the subagent
8
- * returns a partial result, it never runs unbounded.
9
- */
10
- /**
11
- * Resolve the effective limits for one spawn: start from the configured
12
- * ceilings and let overrides only *narrow* them. A request above a ceiling is
13
- * clamped down, not honored — "budget overrides within limits" by construction.
14
- */
15
- export declare function resolveBudget(defaults: BudgetLimits, overrides?: Partial<BudgetLimits>): BudgetLimits;
16
- /**
17
- * A live budget for one subagent run. The wall clock starts at construction
18
- * (spawn time); the clock source is injectable so tests never sleep.
19
- */
20
- export declare class Budget implements LoopBudget {
21
- private readonly limits;
22
- private readonly now;
23
- private readonly startedAt;
24
- constructor(limits: BudgetLimits, now?: () => number);
25
- /**
26
- * The reason to stop before the next model turn, or `null` to continue.
27
- * Checked at iteration boundaries — the in-flight turn always completes, so
28
- * overshoot is bounded by one turn.
29
- */
30
- exceeded(state: {
31
- iterations: number;
32
- usage: Usage;
33
- }): string | null;
34
- }