@cryer/star-cli 0.3.10 → 0.3.11

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.
@@ -12,7 +12,7 @@ import {
12
12
  toCoreMessages,
13
13
  toStarMessage,
14
14
  trackTree
15
- } from "./chunk-IJGUW4PM.js";
15
+ } from "./chunk-N4MIQSNZ.js";
16
16
  import {
17
17
  createModel,
18
18
  streamActivity
@@ -24,7 +24,7 @@ import {
24
24
  ToolRegistry,
25
25
  createDefaultRegistry,
26
26
  debugStreamLog
27
- } from "./chunk-URC7JXKX.js";
27
+ } from "./chunk-Q5FJN4LN.js";
28
28
  import {
29
29
  TodoStore,
30
30
  beginTurn,
@@ -43,7 +43,7 @@ import {
43
43
  stripOversizedImages,
44
44
  undoTurnSnapshots,
45
45
  userMemoryPath
46
- } from "./chunk-A5PXPWHB.js";
46
+ } from "./chunk-LSYGHLRU.js";
47
47
 
48
48
  // src/agent/loop.ts
49
49
  import { tool as aiTool, generateText as generateText3 } from "ai";
@@ -76,6 +76,9 @@ function textWeight(text) {
76
76
  }
77
77
  return text.length + cjk * (CHARS_PER_TOKEN - 1);
78
78
  }
79
+ function roughTextTokens(text) {
80
+ return Math.ceil(textWeight(text) / CHARS_PER_TOKEN);
81
+ }
79
82
  function contentTokenLength(message) {
80
83
  if (message.role === "tool") {
81
84
  let tokens2 = 0;
@@ -241,6 +244,7 @@ ${transcript}`,
241
244
  }
242
245
  function compactMessages(messages, maxTokens, opts = {}) {
243
246
  const limit = opts.force ? 0 : maxTokens;
247
+ const minKept = opts.force ? 1 : MIN_KEPT_MESSAGES;
244
248
  const overhead = opts.overheadTokens ?? 0;
245
249
  const tokens = messages.map((star) => estimateMessageTokens(star.message));
246
250
  let total = overhead;
@@ -269,7 +273,7 @@ function compactMessages(messages, maxTokens, opts = {}) {
269
273
  let droppedCount = 0;
270
274
  let droppedTokens = 0;
271
275
  for (const turn of turns) {
272
- if (restCount - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
276
+ if (restCount - droppedCount - turn.length < minKept) {
273
277
  break;
274
278
  }
275
279
  droppedCount += turn.length;
@@ -299,6 +303,7 @@ var KEEP_RECENT_MESSAGES = 24;
299
303
  var MIN_ELISION_TOKENS = 250;
300
304
  var ELIDED_TOOL_RESULT_TEXT = "[stale tool output elided to free context \u2014 it was shown in full earlier; re-run the tool if you need it again]";
301
305
  var ELIDED_IMAGE_TEXT = "[image elided to free context \u2014 re-attach it if it is still needed]";
306
+ var ELIDED_TOOL_CALL_ARGS_TEXT = "arguments elided to free context \u2014 the call already ran; this placeholder is not valid tool input, re-read the file if you need the old content";
302
307
  function elideStaleContent(messages, thresholdTokens, overheadTokens = 0) {
303
308
  let total = overheadTokens;
304
309
  for (const star of messages) total += estimateMessageTokens(star.message);
@@ -324,6 +329,19 @@ function elideStaleContent(messages, thresholdTokens, overheadTokens = 0) {
324
329
  meta: { ...star.meta, elided: true }
325
330
  };
326
331
  elidedCount++;
332
+ } else if (message.role === "assistant" && Array.isArray(message.content)) {
333
+ if (!message.content.some((part) => part.type === "tool-call")) continue;
334
+ if (estimateMessageTokens(message) < MIN_ELISION_TOKENS) continue;
335
+ out[i] = {
336
+ message: {
337
+ ...message,
338
+ content: message.content.map(
339
+ (part) => part.type === "tool-call" ? { ...part, args: { elided: ELIDED_TOOL_CALL_ARGS_TEXT } } : part
340
+ )
341
+ },
342
+ meta: { ...star.meta, elided: true }
343
+ };
344
+ elidedCount++;
327
345
  } else if (message.role === "user" && Array.isArray(message.content)) {
328
346
  if (!message.content.some((part) => part.type === "image" || part.type === "file")) continue;
329
347
  out[i] = {
@@ -709,7 +727,7 @@ function summarizeStreamError(error) {
709
727
  }
710
728
 
711
729
  // src/llm/stream.ts
712
- import { streamText } from "ai";
730
+ import { InvalidToolArgumentsError, streamText } from "ai";
713
731
  var DEFAULT_IDLE_TIMEOUT_MS = 6e4;
714
732
  var DEFAULT_FIRST_PART_TIMEOUT_MS = 12e4;
715
733
  var CONTENT_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "tool-call", "tool-call-delta"]);
@@ -791,6 +809,7 @@ async function* streamChat(opts) {
791
809
  let deltas = 0;
792
810
  let hasContent = false;
793
811
  const argsProgress = /* @__PURE__ */ new Map();
812
+ let invalidArgsCounter = 0;
794
813
  let lastPartAt = Date.now();
795
814
  debugStreamLog("request", {
796
815
  messages: opts.messages.length,
@@ -863,6 +882,7 @@ async function* streamChat(opts) {
863
882
  break;
864
883
  case "tool-call":
865
884
  hasContent = true;
885
+ argsProgress.delete(part.toolCallId);
866
886
  yield {
867
887
  type: "tool-call",
868
888
  id: part.toolCallId,
@@ -902,6 +922,22 @@ async function* streamChat(opts) {
902
922
  usage: part.usage,
903
923
  deltas
904
924
  });
925
+ for (const [id, progress] of argsProgress) {
926
+ debugStreamLog("truncated-tool-call", {
927
+ toolCallId: id,
928
+ toolName: progress.name,
929
+ chars: progress.chars,
930
+ finishReason: part.finishReason
931
+ });
932
+ yield {
933
+ type: "tool-call",
934
+ id,
935
+ name: progress.name,
936
+ args: {},
937
+ invalidArgs: `the arguments were cut off mid-stream after ${progress.chars} characters (finish reason: ${part.finishReason}) \u2014 the reply hit the output or context limit before the JSON completed. Reissue ${progress.name} with complete arguments; for large content, split it into several smaller calls.`
938
+ };
939
+ }
940
+ argsProgress.clear();
905
941
  yield {
906
942
  type: "finish",
907
943
  finishReason: part.finishReason,
@@ -916,6 +952,29 @@ async function* streamChat(opts) {
916
952
  }
917
953
  case "error": {
918
954
  if (opts.abortSignal?.aborted) return;
955
+ if (InvalidToolArgumentsError.isInstance(part.error)) {
956
+ const invalid = part.error;
957
+ let args = invalid.toolArgs;
958
+ try {
959
+ args = JSON.parse(invalid.toolArgs);
960
+ } catch {
961
+ }
962
+ const started = [...argsProgress.entries()].reverse().find(([, progress]) => progress.name === invalid.toolName);
963
+ debugStreamLog("invalid-tool-args", {
964
+ toolName: invalid.toolName,
965
+ recoveredId: started?.[0] ?? null
966
+ });
967
+ yield {
968
+ type: "tool-call",
969
+ id: started?.[0] ?? `invalid-args-${++invalidArgsCounter}`,
970
+ name: invalid.toolName,
971
+ args,
972
+ // The zod detail tells the model exactly which field to fix;
973
+ // capped because the message embeds the full rejected value.
974
+ invalidArgs: invalid.message.length > 2e3 ? `${invalid.message.slice(0, 2e3)}\u2026` : invalid.message
975
+ };
976
+ return;
977
+ }
919
978
  const error = toStreamErrorInfo(part.error);
920
979
  debugStreamLog("error-part", { seenContent, deltas, error: summarizeStreamError(error) });
921
980
  yield { type: "error", error };
@@ -2087,7 +2146,7 @@ function findModelPricing(config, name, modelId) {
2087
2146
  return config.models.find((m) => m.model === modelId);
2088
2147
  }
2089
2148
  async function runSubagent(deps, args, cwd, signal, agentIdRef) {
2090
- const { AgentLoop: AgentLoop2 } = await import("./loop-TW2FHL7J.js");
2149
+ const { AgentLoop: AgentLoop2 } = await import("./loop-AY4PXRVP.js");
2091
2150
  const child = new AgentLoop2({
2092
2151
  model: deps.model,
2093
2152
  registry: createDefaultRegistry(),
@@ -2284,6 +2343,7 @@ function finalNudgeWarning(autoContinues, maxAutoContinues) {
2284
2343
  return autoContinues >= maxAutoContinues ? " This is the final automatic continuation \u2014 if your next reply again has no tool calls, nudging stops and the turn is handed back to the user. Finish the work now or summarize what remains." : "";
2285
2344
  }
2286
2345
  var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
2346
+ var TODO_STALE_NUDGE_STEPS = 20;
2287
2347
  function attachTurnContext(messages, context) {
2288
2348
  if (!context) return messages;
2289
2349
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -2422,6 +2482,29 @@ var AgentLoop = class {
2422
2482
  // (the tool map's share of the request window) is computed alongside since
2423
2483
  // it depends on the same inputs.
2424
2484
  aiToolsCache = null;
2485
+ // Server-calibration baseline for the context estimate: the latest finish
2486
+ // event's provider-reported promptTokens paired with the local estimate of
2487
+ // the exact request that produced it. Non-OpenAI tokenizers can count the
2488
+ // same history far higher than the local cl100k estimate (a qwen endpoint
2489
+ // counted ~30% more on a Chinese-heavy code session), which let the ctx%
2490
+ // display and the auto-compaction trigger lag the real window fill until
2491
+ // the provider itself started truncating replies. The delta refreshes on
2492
+ // every step's finish, so drift is bounded to one step's new content.
2493
+ serverTokenBaseline = null;
2494
+ // Text streamed by the in-flight reply (visible text + reasoning +
2495
+ // completed tool-call args). estimateContextTokens folds a rough token
2496
+ // estimate of it into the status-bar ctx% so the display tracks the
2497
+ // server's real-time occupancy — prompt PLUS generated-so-far, which is
2498
+ // what llama.cpp-style monitors show — instead of lagging it by the whole
2499
+ // in-flight generation (10k reasoning tokens ≈ 8% of a 128k window).
2500
+ // Reset when each stream attempt ends: the persisted reply is then counted
2501
+ // through the history estimate, and reasoning — never resent — drops out.
2502
+ // User steering prompts submitted while a turn is running (the REPL's
2503
+ // "insert now" choice). Drained into the history as synthetic user
2504
+ // messages at the next step boundary — the in-flight model reply and its
2505
+ // tool calls always finish first, so a steer never interrupts mid-step.
2506
+ pendingSteers = [];
2507
+ inflightStreamText = "";
2425
2508
  // Lazily resolved auxiliary model (config.smallModel) for cheap side calls
2426
2509
  // — compaction summaries, session titles, completion checks. Undefined =
2427
2510
  // use the main model; a resolution failure is surfaced once as a notice.
@@ -2487,12 +2570,56 @@ var AgentLoop = class {
2487
2570
  getStarMessages() {
2488
2571
  return this.messages;
2489
2572
  }
2573
+ // Queue a user steering prompt for the running turn. Delivery happens at
2574
+ // the next step boundary (see drainSteers); if the turn ends first the
2575
+ // text stays queued and takeUndeliveredSteers hands it back to the caller.
2576
+ injectUserMessage(text) {
2577
+ const trimmed = text.trim();
2578
+ if (trimmed.length > 0) this.pendingSteers.push(trimmed);
2579
+ }
2580
+ // Steers that never reached a step boundary (turn ended, aborted or
2581
+ // errored first). The caller decides their fate — the REPL re-queues them
2582
+ // as ordinary prompts so user input is never dropped.
2583
+ takeUndeliveredSteers() {
2584
+ return this.pendingSteers.splice(0);
2585
+ }
2586
+ // Append queued steers to the history as synthetic user messages: they
2587
+ // belong to the turn in progress, so /undo, /rewind and compaction must
2588
+ // not treat them as turn boundaries. Returns the number delivered.
2589
+ async drainSteers() {
2590
+ const steers = this.pendingSteers.splice(0);
2591
+ for (const text of steers) {
2592
+ const message = { role: "user", content: text };
2593
+ const meta = { synthetic: "steer" };
2594
+ this.messages.push({ message, meta });
2595
+ await this.persist(message, meta);
2596
+ }
2597
+ return steers.length;
2598
+ }
2490
2599
  // Wire-schema token overhead of the active tool set (memoized by
2491
2600
  // buildAiTools). The auto-compact budget includes it; surfaced here so the
2492
2601
  // status bar ctx% can show the same total the compaction decision uses.
2493
2602
  getToolSchemaTokens() {
2494
2603
  return this.buildAiTools().schemaTokens;
2495
2604
  }
2605
+ // Additive correction from serverTokenBaseline, clamped ≥ 0: an
2606
+ // under-reading provider estimate must pull compaction earlier (the bug
2607
+ // this fixes), but an over-reading one must never push it later than the
2608
+ // local estimate alone would — compacting early is safe, late is fatal.
2609
+ calibrationDelta() {
2610
+ const baseline = this.serverTokenBaseline;
2611
+ if (!baseline) return 0;
2612
+ return Math.max(0, baseline.serverTokens - baseline.localTokens);
2613
+ }
2614
+ // Effective context-window usage in tokens: local BPE estimate + tool
2615
+ // schemas + the server-calibration delta + the in-flight reply's streamed
2616
+ // output. This is the status bar's live ctx% view; the elision/compaction
2617
+ // budgets deliberately use only the first three (history + schemas +
2618
+ // calibration), since in-flight reasoning is never resent and must not
2619
+ // trigger a compaction the next request won't need.
2620
+ estimateContextTokens() {
2621
+ return estimateTokens(this.getMessages()) + this.getToolSchemaTokens() + this.calibrationDelta() + (this.inflightStreamText.length > 0 ? roughTextTokens(this.inflightStreamText) : 0);
2622
+ }
2496
2623
  // Auxiliary model for cheap side calls: config.smallModel, resolved once.
2497
2624
  // An unresolvable name/key degrades to the main model instead of breaking
2498
2625
  // turns; the failure rides out once as a notice at the next turn start.
@@ -2832,6 +2959,9 @@ var AgentLoop = class {
2832
2959
  let usedToolsThisTurn = false;
2833
2960
  let streamCutContinues = 0;
2834
2961
  let openTodos = [];
2962
+ let todoRevision = this.opts.registry.todos.revision;
2963
+ let stepsSinceTodoChange = 0;
2964
+ const todoStaleNudgeSteps = this.opts.todoStaleNudgeSteps ?? TODO_STALE_NUDGE_STEPS;
2835
2965
  const maxSteps = config.maxSteps;
2836
2966
  let step = 0;
2837
2967
  let toolExecutedSinceCheckpoint = false;
@@ -2868,16 +2998,47 @@ ${finished.result}`
2868
2998
  await this.persist(note, meta);
2869
2999
  }
2870
3000
  }
3001
+ const steered = await this.drainSteers();
3002
+ if (steered > 0) {
3003
+ yield {
3004
+ type: "notice",
3005
+ message: `${steered} steering message(s) delivered to the model; taking effect from this step.`
3006
+ };
3007
+ }
3008
+ const currentTodoRevision = registry.todos.revision;
3009
+ if (currentTodoRevision !== todoRevision) {
3010
+ todoRevision = currentTodoRevision;
3011
+ stepsSinceTodoChange = 0;
3012
+ } else if (stepsSinceTodoChange >= todoStaleNudgeSteps) {
3013
+ const open = registry.todos.list().filter((t) => t.status !== "done");
3014
+ if (open.length > 0) {
3015
+ stepsSinceTodoChange = 0;
3016
+ yield {
3017
+ type: "notice",
3018
+ message: `todo list unchanged for ${todoStaleNudgeSteps} steps with ${open.length} open item(s); reminding the model to update it.`
3019
+ };
3020
+ const reminder = {
3021
+ role: "user",
3022
+ content: `[todo reminder] Your todo list still has ${open.length} open item(s), unchanged for ${todoStaleNudgeSteps} steps: ${open.slice(0, 5).map((t) => `"${t.title}"`).join(
3023
+ ", "
3024
+ )}${open.length > 5 ? ", \u2026" : ""}. Update the list with todo_write so it reflects what is done and what you are working on now, then continue working.`
3025
+ };
3026
+ const meta = { synthetic: "nudge" };
3027
+ this.messages.push({ message: reminder, meta });
3028
+ await this.persist(reminder, meta);
3029
+ }
3030
+ }
2871
3031
  const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2872
3032
  const threshold = resolveCompactThreshold(config, maxTokens);
2873
- const elided = elideStaleContent(this.messages, threshold, toolSchemaTokens);
3033
+ const calibration = this.calibrationDelta();
3034
+ const elided = elideStaleContent(this.messages, threshold, toolSchemaTokens + calibration);
2874
3035
  if (elided) {
2875
3036
  this.messages = elided.messages;
2876
3037
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
2877
3038
  this.opts.registry?.resetVolatileState();
2878
3039
  }
2879
3040
  const compacted = compactMessages(this.messages, threshold, {
2880
- overheadTokens: toolSchemaTokens
3041
+ overheadTokens: toolSchemaTokens + calibration
2881
3042
  });
2882
3043
  if (compacted.compacted) {
2883
3044
  this.messages = await this.applyCompactionSummary(compacted, signal);
@@ -2885,6 +3046,7 @@ ${finished.result}`
2885
3046
  this.turnMarkers = [];
2886
3047
  this.opts.registry?.resetVolatileState();
2887
3048
  }
3049
+ const requestLocalTokens = estimateTokens(this.getMessages()) + toolSchemaTokens;
2888
3050
  const maxRetries = Math.max(0, config.streamMaxRetries);
2889
3051
  const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
2890
3052
  let text = "";
@@ -2905,22 +3067,37 @@ ${finished.result}`
2905
3067
  lastUsage = void 0;
2906
3068
  sawReasoning = false;
2907
3069
  wasTruncated = false;
3070
+ this.inflightStreamText = "";
2908
3071
  const timeoutScale = Math.min(attempt + 1, 3);
2909
3072
  try {
2910
3073
  for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
2911
3074
  if (event.type === "text-delta") {
2912
3075
  text += event.text;
3076
+ this.inflightStreamText += event.text;
2913
3077
  yield event;
2914
3078
  } else if (event.type === "reasoning") {
2915
3079
  sawReasoning = true;
3080
+ this.inflightStreamText += event.text;
2916
3081
  yield event;
2917
3082
  } else if (event.type === "tool-call") {
2918
- toolCalls.push({ id: event.id, name: event.name, args: event.args });
3083
+ this.inflightStreamText += JSON.stringify(event.args ?? null);
3084
+ toolCalls.push({
3085
+ id: event.id,
3086
+ name: event.name,
3087
+ args: event.args,
3088
+ ...event.invalidArgs !== void 0 ? { invalidArgs: event.invalidArgs } : {}
3089
+ });
2919
3090
  yield event;
2920
3091
  } else if (event.type === "finish") {
2921
3092
  lastFinishReason = event.finishReason;
2922
3093
  if (event.truncated) wasTruncated = true;
2923
3094
  lastUsage = event.usage;
3095
+ if (event.usage && event.usage.promptTokens > 0) {
3096
+ this.serverTokenBaseline = {
3097
+ localTokens: requestLocalTokens,
3098
+ serverTokens: event.usage.promptTokens
3099
+ };
3100
+ }
2924
3101
  yield this.foldSubagentUsage(event);
2925
3102
  } else if (event.type === "error") {
2926
3103
  failure = event.error;
@@ -2933,6 +3110,7 @@ ${finished.result}`
2933
3110
  failure = toStreamErrorInfo(error);
2934
3111
  }
2935
3112
  }
3113
+ this.inflightStreamText = "";
2936
3114
  if (signal.aborted) {
2937
3115
  yield* this.persistInterrupted(text, toolCalls);
2938
3116
  await this.runStopHooks("aborted");
@@ -2985,6 +3163,14 @@ ${finished.result}`
2985
3163
  return;
2986
3164
  }
2987
3165
  if (!hasOutput()) {
3166
+ const steered2 = await this.drainSteers();
3167
+ if (steered2 > 0) {
3168
+ yield {
3169
+ type: "notice",
3170
+ message: `${steered2} steering message(s) delivered to the model after an empty reply.`
3171
+ };
3172
+ continue;
3173
+ }
2988
3174
  if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
2989
3175
  autoContinues++;
2990
3176
  lastWasNudge = true;
@@ -3026,6 +3212,16 @@ ${finished.result}`
3026
3212
  this.messages.push({ message: assistantMessage });
3027
3213
  await this.persist(assistantMessage);
3028
3214
  if (toolCalls.length === 0) {
3215
+ const steered2 = await this.drainSteers();
3216
+ if (steered2 > 0) {
3217
+ autoContinues = 0;
3218
+ lastWasNudge = false;
3219
+ yield {
3220
+ type: "notice",
3221
+ message: `${steered2} steering message(s) delivered to the model; the turn continues.`
3222
+ };
3223
+ continue;
3224
+ }
3029
3225
  if (wasTruncated) {
3030
3226
  if (config.permissionMode !== "plan" && streamCutContinues < config.streamMaxRetries) {
3031
3227
  streamCutContinues++;
@@ -3114,7 +3310,8 @@ ${finished.result}`
3114
3310
  try {
3115
3311
  for (const call of toolCalls) {
3116
3312
  if (signal.aborted) break;
3117
- const refusal = this.doomLoopRefusal(call);
3313
+ const refusal = call.invalidArgs !== void 0 ? `Tool call not executed \u2014 ${call.invalidArgs}
3314
+ Reissue ${call.name} with valid arguments.` : this.doomLoopRefusal(call);
3118
3315
  if (refusal !== null) {
3119
3316
  yield { type: "notice", message: refusal };
3120
3317
  } else {
@@ -3194,6 +3391,7 @@ ${finished.result}`
3194
3391
  return;
3195
3392
  }
3196
3393
  step++;
3394
+ stepsSinceTodoChange++;
3197
3395
  }
3198
3396
  }
3199
3397
  // The permission mode can change at runtime, so the system message is
@@ -3507,4 +3705,4 @@ export {
3507
3705
  attachTurnContext,
3508
3706
  AgentLoop
3509
3707
  };
3510
- //# sourceMappingURL=chunk-MNVCCOI2.js.map
3708
+ //# sourceMappingURL=chunk-63CGUF3F.js.map