@awak-app/simy-cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,19 @@ executor updates, repository selection, and CLI reconnection. Web clients must
93
93
  validate the action type before rendering or executing it; raw local error text
94
94
  is not copied into the recovery contract.
95
95
 
96
+ ## Provider token budget
97
+
98
+ Each run accepts a configurable `token_budget` (or the explicit
99
+ `provider_token_budget` alias) from 1 to 10,000,000 Provider tokens. The default
100
+ is 250,000. Provider tokens are the Codex or Claude Code usage reported for the
101
+ run; they are a safety limit and are not billed as SIMY tokens.
102
+
103
+ When a run reaches this limit, its recovery contract suggests a higher rounded
104
+ limit. SIMY Web can edit that suggestion and call
105
+ `POST /v1/agentic-loop/:run_id/provider-token-budget` with
106
+ `provider_token_budget`. The CLI persists the higher limit and resumes the same
107
+ run. SIMY token consumption remains a separate platform billing record.
108
+
96
109
  ## Interactive console
97
110
 
98
111
  Running `simy` in a terminal opens the Agentic Loop chat. It discovers the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Local SIMY Agentic Loop executor for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  continueLocalCodingRun,
8
8
  continueLocalCodingRunAfterRepositoryApproval,
9
9
  createRun,
10
+ increaseLocalCodingRunProviderTokenBudget,
10
11
  LocalRunRegistry,
11
12
  isLocalRepositoryApprovalPending,
12
13
  pauseLocalCodingRun,
@@ -468,7 +469,7 @@ export async function startAgent({
468
469
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
469
470
  max_attempts: body.max_attempts,
470
471
  retry_budget: body.retry_budget,
471
- token_budget: body.token_budget,
472
+ token_budget: body.provider_token_budget ?? body.token_budget,
472
473
  ui_evidence_root:
473
474
  typeof body.ui_evidence_root === "string" ? body.ui_evidence_root : "",
474
475
  acceptance_criteria: Array.isArray(body.acceptance_criteria)
@@ -553,6 +554,53 @@ export async function startAgent({
553
554
  return;
554
555
  }
555
556
 
557
+ const providerBudgetMatch = agenticLoopPath.match(
558
+ /^\/v1\/agentic-loop\/([^/]+)\/provider-token-budget$/,
559
+ );
560
+ if (req.method === "POST" && providerBudgetMatch) {
561
+ if (!acceptsNewWork(updateManager)) {
562
+ json(res, 503, updateInProgressResponse(updateManager));
563
+ return;
564
+ }
565
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
566
+ json(res, 401, { error: "simy session expired; run simy again" });
567
+ return;
568
+ }
569
+ const run = registry.get(decodeURIComponent(providerBudgetMatch[1]));
570
+ if (!run) {
571
+ json(res, 404, { error: "run not found" });
572
+ return;
573
+ }
574
+ const body = await readJson(req);
575
+ let budget;
576
+ try {
577
+ budget = await increaseLocalCodingRunProviderTokenBudget(
578
+ run,
579
+ body.provider_token_budget,
580
+ );
581
+ } catch (error) {
582
+ json(res, 409, {
583
+ error: error instanceof Error ? error.message : "Provider token budget was rejected",
584
+ code: "provider_token_budget_rejected",
585
+ });
586
+ return;
587
+ }
588
+ const continuation = continueLocalCodingRun(
589
+ run,
590
+ String(body.message || "Continue after increasing the Provider token budget."),
591
+ runOptions,
592
+ );
593
+ json(res, 202, {
594
+ ok: true,
595
+ run_id: run.id,
596
+ state: "resuming",
597
+ provider_token_budget: budget.token_budget,
598
+ provider_tokens_used: budget.tokens_used,
599
+ });
600
+ void continuation.catch((error) => reportProviderBudgetResumeError(error, quiet));
601
+ return;
602
+ }
603
+
556
604
  const guidanceMatch = agenticLoopPath.match(/^\/v1\/agentic-loop\/([^/]+)\/guidance$/);
557
605
  if (req.method === "POST" && guidanceMatch) {
558
606
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
@@ -964,6 +1012,14 @@ function reportRepositoryResumeError(error, quiet) {
964
1012
  );
965
1013
  }
966
1014
 
1015
+ function reportProviderBudgetResumeError(error, quiet) {
1016
+ if (!quiet) {
1017
+ console.error(
1018
+ `Provider token budget was increased, but the run could not resume: ${error instanceof Error ? error.message : error}`,
1019
+ );
1020
+ }
1021
+ }
1022
+
967
1023
  function localRepositoryHilRequestId(run) {
968
1024
  return `local-repository-scan:${run.id}`;
969
1025
  }
@@ -1,4 +1,4 @@
1
- export const CLI_VERSION = "0.2.0";
1
+ export const CLI_VERSION = "0.2.1";
2
2
  export const CLI_API_CONTRACT_VERSION = 2;
3
3
 
4
4
  export function withCliContract(capabilities = {}, installMode = null) {
@@ -2,7 +2,8 @@ import { clampAttempts } from "./shared.js";
2
2
 
3
3
  export const DEFAULT_TOKEN_BUDGET = 250_000;
4
4
  export const DEFAULT_RETRY_BUDGET = 2;
5
- const MAX_TOKEN_BUDGET = 10_000_000;
5
+ export const MAX_PROVIDER_TOKEN_BUDGET = 10_000_000;
6
+ const PROVIDER_TOKEN_BUDGET_STEP = 250_000;
6
7
 
7
8
  export function resolveRunBudgets(request = {}) {
8
9
  const legacyMaxAttempts = clampAttempts(request.max_attempts);
@@ -12,12 +13,47 @@ export function resolveRunBudgets(request = {}) {
12
13
  ? legacyMaxAttempts - 1
13
14
  : Math.min(4, Math.max(0, explicitRetryBudget));
14
15
  return {
15
- token_budget: normalizeTokenBudget(request.token_budget),
16
+ token_budget: normalizeTokenBudget(request.provider_token_budget ?? request.token_budget),
16
17
  retry_budget: retryBudget,
17
18
  max_attempts: retryBudget + 1,
18
19
  };
19
20
  }
20
21
 
22
+ export function suggestedProviderTokenBudget(value) {
23
+ const state = value?.schema_version === 1 ? value : budgetState(value);
24
+ if (state.tokens_used >= MAX_PROVIDER_TOKEN_BUDGET) return null;
25
+ const required = Math.max(
26
+ state.token_budget * 2,
27
+ state.tokens_used * 1.25,
28
+ state.tokens_used + PROVIDER_TOKEN_BUDGET_STEP,
29
+ );
30
+ return Math.min(
31
+ MAX_PROVIDER_TOKEN_BUDGET,
32
+ Math.ceil(required / PROVIDER_TOKEN_BUDGET_STEP) * PROVIDER_TOKEN_BUDGET_STEP,
33
+ );
34
+ }
35
+
36
+ export function increaseProviderTokenBudget(snapshot, value) {
37
+ const requested = integer(value);
38
+ if (requested === null || requested <= 0) {
39
+ throw new Error("Provider token budget must be a positive integer.");
40
+ }
41
+ if (requested > MAX_PROVIDER_TOKEN_BUDGET) {
42
+ throw new Error(
43
+ `Provider token budget cannot exceed ${MAX_PROVIDER_TOKEN_BUDGET.toLocaleString("en-US")}.`,
44
+ );
45
+ }
46
+ const current = budgetState(snapshot);
47
+ if (requested <= current.token_budget) {
48
+ throw new Error("Provider token budget must be higher than the current limit.");
49
+ }
50
+ if (requested <= current.tokens_used) {
51
+ throw new Error("Provider token budget must be higher than the Provider tokens already used.");
52
+ }
53
+ snapshot.charter.token_budget = requested;
54
+ return refreshBudgetState(snapshot);
55
+ }
56
+
21
57
  export function budgetState(snapshot, pendingAttempt = null) {
22
58
  const attempts = [
23
59
  ...(Array.isArray(snapshot?.attempts) ? snapshot.attempts : []),
@@ -93,7 +129,7 @@ function recordTokenTotal(value) {
93
129
  function normalizeTokenBudget(value) {
94
130
  const parsed = integer(value);
95
131
  if (parsed === null || parsed <= 0) return DEFAULT_TOKEN_BUDGET;
96
- return Math.min(MAX_TOKEN_BUDGET, parsed);
132
+ return Math.min(MAX_PROVIDER_TOKEN_BUDGET, parsed);
97
133
  }
98
134
 
99
135
  function normalizeRetryBudget(value, maxAttempts) {
@@ -469,7 +469,7 @@ async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
469
469
  if (attempt) attempt.budget = budget;
470
470
  const message =
471
471
  reason === "token_budget_exhausted"
472
- ? "Agentic loop exhausted its token budget."
472
+ ? "Agentic loop reached its Provider token budget."
473
473
  : "Agentic loop exhausted its retry budget.";
474
474
  appendEvent(snapshot, "blocked", message, {
475
475
  code: reason,
@@ -487,8 +487,8 @@ function budgetFinding(reason, budget) {
487
487
  passed: false,
488
488
  code: "TOKEN_BUDGET_EXHAUSTED",
489
489
  severity: "blocker",
490
- target: "token_budget",
491
- explanation: `The run used ${budget.tokens_used} of ${budget.token_budget} provider tokens, so no additional executor or reviewer call can start.`,
490
+ target: "provider_token_budget",
491
+ explanation: `The run used ${budget.tokens_used} of ${budget.token_budget} Provider tokens, so no additional executor or reviewer call can start until the Provider token budget is increased. Provider tokens are not billed as SIMY tokens.`,
492
492
  repairability: "manual",
493
493
  auto_fix_hint: null,
494
494
  };
@@ -1,3 +1,5 @@
1
+ import { suggestedProviderTokenBudget } from "./budget.js";
2
+
1
3
  const RECOVERABLE_STATES = new Set(["waiting_human", "blocked", "failed"]);
2
4
 
3
5
  export function recoveryContractForEvent({ snapshot, state, message, detail = {} }) {
@@ -6,7 +8,7 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
6
8
 
7
9
  const reasonCode = recoveryReasonCode(state, message, detail);
8
10
  const attemptNumber = positiveInteger(detail.attempt_number);
9
- const actions = recoveryActions(reasonCode);
11
+ const actions = recoveryActions(reasonCode, detail.budget);
10
12
  return {
11
13
  schema_version: 1,
12
14
  state,
@@ -18,6 +20,8 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
18
20
  event_code: clean(detail.code) || null,
19
21
  attempt_number: attemptNumber,
20
22
  max_attempts: positiveInteger(snapshot?.charter?.max_attempts),
23
+ provider_token_budget: positiveInteger(snapshot?.charter?.token_budget),
24
+ provider_tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
21
25
  token_budget: positiveInteger(snapshot?.charter?.token_budget),
22
26
  retry_budget: nonNegativeInteger(snapshot?.charter?.retry_budget),
23
27
  tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
@@ -136,7 +140,7 @@ function recoveryMessage(reasonCode) {
136
140
  "SIMY needs the missing requirement or approval before implementation can start.",
137
141
  human_input_required: "SIMY needs your decision or clarification before it can continue.",
138
142
  token_budget_exhausted:
139
- "This run used its token budget. Start a new run if you want SIMY to continue with a fresh limit.",
143
+ "This run reached its Provider token safety limit. Provider tokens are not billed by SIMY. Increase the limit to continue this run.",
140
144
  retry_budget_exhausted:
141
145
  "This run used its automatic retry budget. Start a new run to continue with a fresh limit.",
142
146
  automatic_progress_blocked:
@@ -149,7 +153,7 @@ function recoveryMessage(reasonCode) {
149
153
  return messages[reasonCode] || messages.automatic_progress_blocked;
150
154
  }
151
155
 
152
- function recoveryActions(reasonCode) {
156
+ function recoveryActions(reasonCode, budget = null) {
153
157
  if (reasonCode === "repository_authorization_required") {
154
158
  return [
155
159
  {
@@ -182,7 +186,25 @@ function recoveryActions(reasonCode) {
182
186
  newRunAction(),
183
187
  ];
184
188
  }
185
- if (reasonCode === "token_budget_exhausted" || reasonCode === "retry_budget_exhausted") {
189
+ if (reasonCode === "token_budget_exhausted") {
190
+ const suggestedBudget = suggestedProviderTokenBudget(budget);
191
+ if (!suggestedBudget || budget?.retry_exhausted === true) return [newRunAction()];
192
+ return [
193
+ {
194
+ id: "increase_provider_token_budget",
195
+ type: "increase_provider_token_budget",
196
+ label: "Increase Provider token budget",
197
+ description:
198
+ "Raise this run's safety limit and continue. Provider tokens are usage diagnostics and are not billed as SIMY tokens.",
199
+ command: null,
200
+ payload: {
201
+ ...(suggestedBudget ? { suggested_provider_token_budget: suggestedBudget } : {}),
202
+ },
203
+ },
204
+ newRunAction(),
205
+ ];
206
+ }
207
+ if (reasonCode === "retry_budget_exhausted") {
186
208
  return [newRunAction()];
187
209
  }
188
210
  if (reasonCode === "local_execution_interrupted") {
package/src/runner.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  redactExecutionText,
20
20
  } from "./orchestrator/execution-io.js";
21
21
  import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
22
- import { refreshBudgetState } from "./orchestrator/budget.js";
22
+ import { increaseProviderTokenBudget, refreshBudgetState } from "./orchestrator/budget.js";
23
23
  import { appendEvent } from "./orchestrator/shared.js";
24
24
  import {
25
25
  createProviderStreamDecoder,
@@ -252,6 +252,35 @@ export async function continueLocalCodingRun(run, guidance, dependencies = {}) {
252
252
  return executeLocalCodingRun(run, dependencies, { humanGuidance: message, resume: true });
253
253
  }
254
254
 
255
+ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
256
+ if (run.operation || run.child) {
257
+ throw new Error("Provider token budget can change only while the run is waiting.");
258
+ }
259
+ if (!RESUMABLE_STATES.has(run.status)) {
260
+ throw new Error("Provider token budget can change only for a blocked or waiting run.");
261
+ }
262
+ const currentBudget = refreshBudgetState(run.snapshot);
263
+ if (currentBudget.retry_exhausted) {
264
+ throw new Error("The Agentic Loop run also used its retry budget. Start a new run to continue.");
265
+ }
266
+ const previousBudget = currentBudget.token_budget;
267
+ const budget = increaseProviderTokenBudget(run.snapshot, value);
268
+ run.request.token_budget = budget.token_budget;
269
+ appendEvent(
270
+ run.snapshot,
271
+ "waiting_human",
272
+ `Provider token budget increased from ${previousBudget} to ${budget.token_budget}.`,
273
+ {
274
+ code: "provider_token_budget_increased",
275
+ previous_provider_token_budget: previousBudget,
276
+ provider_token_budget: budget.token_budget,
277
+ budget,
278
+ },
279
+ );
280
+ await updateRun(run, "waiting_human");
281
+ return budget;
282
+ }
283
+
255
284
  export async function continueLocalCodingRunAfterRepositoryApproval(
256
285
  run,
257
286
  { repository, localPath } = {},