@theokit/sdk 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d7057f2: Add a **doom-loop / no-progress guard** to the agent loop. The loop now detects when the model repeats IDENTICAL tool calls (same name + same canonical input) that make no progress — the qwen3-coder `read_file`/`not_found` failure mode where the model retries the same failing call and the run grinds to the iteration ceiling — and stops early with a typed `no_progress` terminal instead of hanging. A pure `DoomLoopTracker` (canonical key-sorted-JSON signature + a consecutive-identical counter) escalates from a one-time guidance nudge at a soft threshold to a hard stop; the hard stop surfaces on `RunResult.stoppedByDoomLoop` and, through the continuation driver, as `terminal: "no_progress"` (so the outer loop does not re-send). It complements — does not replace — the existing empty-round `no_progress` (a different failure mode: model stuck repeating vs model gone silent). On by default with generous thresholds (soft 3 / hard 5); tune or disable per send via `SendOptions.doomLoop` (`false` to disable, or `{ softThreshold, hardThreshold }` to tune). Dependency-free. Grounded in a SOTA study of cline's LoopDetectionTracker + opencode's doom-loop.
8
+
3
9
  ## 2.14.0
4
10
 
5
11
  ### Minor Changes
@@ -2336,6 +2336,7 @@ function applyScriptMetrics(base, script) {
2336
2336
  if (script.usage !== void 0) base.usage = script.usage;
2337
2337
  if (script.cost !== void 0) base.cost = script.cost;
2338
2338
  if (script.stoppedAtIterationLimit === true) base.stoppedAtIterationLimit = true;
2339
+ if (script.stoppedByDoomLoop === true) base.stoppedByDoomLoop = true;
2339
2340
  }
2340
2341
  var FixtureRunBase;
2341
2342
  var init_fixture_run_base = __esm({
@@ -6812,6 +6813,98 @@ var init_budget_gate = __esm({
6812
6813
  }
6813
6814
  });
6814
6815
 
6816
+ // src/internal/agent-loop/doom-loop-tracker.ts
6817
+ function createDoomLoopTracker(option) {
6818
+ if (option === false) return void 0;
6819
+ return new DoomLoopTracker(option);
6820
+ }
6821
+ function assertValidThresholds(soft, hard) {
6822
+ for (const [label, value] of [
6823
+ ["softThreshold", soft],
6824
+ ["hardThreshold", hard]
6825
+ ]) {
6826
+ if (!Number.isInteger(value) || value < 1) {
6827
+ throw new ConfigurationError(
6828
+ `doomLoop.${label} must be a positive integer (received ${value}).`,
6829
+ { code: "invalid_doom_loop_threshold" }
6830
+ );
6831
+ }
6832
+ }
6833
+ }
6834
+ function sortKeys(value) {
6835
+ if (value === null || typeof value !== "object") return value;
6836
+ if (Array.isArray(value)) return value.map(sortKeys);
6837
+ const out = {};
6838
+ for (const key of Object.keys(value).sort()) {
6839
+ out[key] = sortKeys(value[key]);
6840
+ }
6841
+ return out;
6842
+ }
6843
+ function signatureOf(call) {
6844
+ const { input } = call;
6845
+ let inputSig;
6846
+ if (input === null || input === void 0) inputSig = "null";
6847
+ else if (typeof input !== "object") inputSig = String(input);
6848
+ else {
6849
+ try {
6850
+ inputSig = JSON.stringify(sortKeys(input)) ?? "null";
6851
+ } catch {
6852
+ inputSig = String(input);
6853
+ }
6854
+ }
6855
+ return `${call.name}\0${inputSig}`;
6856
+ }
6857
+ function firstDoomLoopVerdict(tracker, calls) {
6858
+ let escalation = { kind: "ok" };
6859
+ for (const call of calls) {
6860
+ const v = tracker.inspect(call);
6861
+ if (v.kind === "hard") return v;
6862
+ if (v.kind === "soft" && escalation.kind === "ok") escalation = v;
6863
+ }
6864
+ return escalation;
6865
+ }
6866
+ var DEFAULT_CONFIG, DoomLoopTracker;
6867
+ var init_doom_loop_tracker = __esm({
6868
+ "src/internal/agent-loop/doom-loop-tracker.ts"() {
6869
+ init_errors();
6870
+ DEFAULT_CONFIG = { softThreshold: 3, hardThreshold: 5 };
6871
+ DoomLoopTracker = class {
6872
+ #config;
6873
+ #lastSignature = "";
6874
+ #count = 0;
6875
+ constructor(config) {
6876
+ const softThreshold = config?.softThreshold ?? DEFAULT_CONFIG.softThreshold;
6877
+ const hardThreshold = config?.hardThreshold ?? DEFAULT_CONFIG.hardThreshold;
6878
+ assertValidThresholds(softThreshold, hardThreshold);
6879
+ this.#config = { softThreshold, hardThreshold };
6880
+ }
6881
+ inspect(call) {
6882
+ const signature = signatureOf(call);
6883
+ this.#count = signature === this.#lastSignature ? this.#count + 1 : 1;
6884
+ this.#lastSignature = signature;
6885
+ const count = this.#count;
6886
+ if (count >= this.#config.hardThreshold) {
6887
+ return {
6888
+ kind: "hard",
6889
+ message: `Detected ${count} consecutive identical calls to \`${call.name}\`; stopping to avoid a loop.`
6890
+ };
6891
+ }
6892
+ if (count === this.#config.softThreshold) {
6893
+ return {
6894
+ kind: "soft",
6895
+ message: `Detected ${count} consecutive identical calls to \`${call.name}\`; try a different approach.`
6896
+ };
6897
+ }
6898
+ return { kind: "ok" };
6899
+ }
6900
+ reset() {
6901
+ this.#lastSignature = "";
6902
+ this.#count = 0;
6903
+ }
6904
+ };
6905
+ }
6906
+ });
6907
+
6815
6908
  // src/internal/budget/usage-accumulator.ts
6816
6909
  var UsageAccumulator;
6817
6910
  var init_usage_accumulator = __esm({
@@ -7001,6 +7094,7 @@ async function initLoopContext(inputs) {
7001
7094
  tools,
7002
7095
  finalText: "",
7003
7096
  finalStatus: "finished",
7097
+ doomLoop: createDoomLoopTracker(inputs.doomLoop),
7004
7098
  usage: new UsageAccumulator(),
7005
7099
  nudgeAttempts: 0,
7006
7100
  stopFeedbackAttempts: 0,
@@ -7053,6 +7147,7 @@ function sanitize(name) {
7053
7147
  var init_loop_context_init = __esm({
7054
7148
  "src/internal/agent-loop/loop-context-init.ts"() {
7055
7149
  init_usage_accumulator();
7150
+ init_doom_loop_tracker();
7056
7151
  init_message_builders();
7057
7152
  }
7058
7153
  });
@@ -8165,6 +8260,7 @@ async function runAgentLoop(inputs) {
8165
8260
  ctx.finalStatus = "error";
8166
8261
  }
8167
8262
  sendSpan?.setAttribute("status", ctx.finalStatus);
8263
+ if (ctx.stoppedByDoomLoop === true) sendSpan?.setAttribute("stoppedByDoomLoop", true);
8168
8264
  if (inputs.telemetry?.includeContent === true && ctx.finalText.length > 0) {
8169
8265
  sendSpan?.addEvent("response", { content: ctx.finalText });
8170
8266
  }
@@ -8191,7 +8287,8 @@ async function runAgentLoop(inputs) {
8191
8287
  ...usage !== void 0 ? { usage } : {},
8192
8288
  ...cost !== void 0 ? { cost } : {},
8193
8289
  ...ctx.error !== void 0 ? { error: ctx.error } : {},
8194
- ...ctx.stoppedAtIterationLimit === true ? { stoppedAtIterationLimit: true } : {}
8290
+ ...ctx.stoppedAtIterationLimit === true ? { stoppedAtIterationLimit: true } : {},
8291
+ ...ctx.stoppedByDoomLoop === true ? { stoppedByDoomLoop: true } : {}
8195
8292
  };
8196
8293
  } finally {
8197
8294
  if (ctxRef !== void 0 && ctxRef.memoryProviderHandle !== void 0 && inputs.memoryProvider !== void 0) {
@@ -8350,8 +8447,26 @@ async function continueOrTerminate(inputs, ctx, llmOutput) {
8350
8447
  }
8351
8448
  }
8352
8449
  pushToolConversationSteps(ctx, llmOutput.toolCalls, toolResults);
8450
+ if (await inspectDoomLoop(inputs, ctx, llmOutput.toolCalls) === "stop") return "done";
8353
8451
  return handleToolErrorContinuation(inputs, ctx, toolResults);
8354
8452
  }
8453
+ async function inspectDoomLoop(inputs, ctx, toolCalls) {
8454
+ if (ctx.doomLoop === void 0) return "continue";
8455
+ const verdict = firstDoomLoopVerdict(ctx.doomLoop, toolCalls);
8456
+ if (verdict.kind === "hard") {
8457
+ ctx.stoppedByDoomLoop = true;
8458
+ await emitAssistantTextStep(
8459
+ inputs,
8460
+ ctx,
8461
+ verdict.message ?? "Stopped: repeated identical tool calls made no progress."
8462
+ );
8463
+ return "stop";
8464
+ }
8465
+ if (verdict.kind === "soft") {
8466
+ ctx.messages.push({ role: "user", content: [{ type: "text", text: verdict.message ?? "" }] });
8467
+ }
8468
+ return "continue";
8469
+ }
8355
8470
  var MAX_NUDGE_ATTEMPTS, MAX_STOP_FEEDBACK_ATTEMPTS;
8356
8471
  var init_loop = __esm({
8357
8472
  "src/internal/agent-loop/loop.ts"() {
@@ -8359,6 +8474,7 @@ var init_loop = __esm({
8359
8474
  init_safe_call();
8360
8475
  init_validate_response();
8361
8476
  init_budget_gate();
8477
+ init_doom_loop_tracker();
8362
8478
  init_loop_context_init();
8363
8479
  init_loop_llm_stream();
8364
8480
  init_message_builders();
@@ -11443,6 +11559,8 @@ function buildLoopInputs(options, runId, userText) {
11443
11559
  // M1-2: per-send iteration ceiling (validated above). The loop reads
11444
11560
  // inputs.maxIterations (default 8 when unset).
11445
11561
  ...maxIterations !== void 0 ? { maxIterations } : {},
11562
+ // Doom-loop guard config (default on; `false` disables, object tunes thresholds).
11563
+ ...options.sendOptions.doomLoop !== void 0 ? { doomLoop: options.sendOptions.doomLoop } : {},
11446
11564
  // D315-D317 — tool lifecycle hooks (cost tracking + audit + retry/alert)
11447
11565
  ...options.agentOptions.onToolStart !== void 0 ? { onToolStart: options.agentOptions.onToolStart } : {},
11448
11566
  ...options.agentOptions.onToolEnd !== void 0 ? { onToolEnd: options.agentOptions.onToolEnd } : {},
@@ -11591,6 +11709,7 @@ var init_real_local_run = __esm({
11591
11709
  if (output.usage !== void 0) this.script.usage = output.usage;
11592
11710
  if (output.cost !== void 0) this.script.cost = output.cost;
11593
11711
  if (output.stoppedAtIterationLimit === true) this.script.stoppedAtIterationLimit = true;
11712
+ if (output.stoppedByDoomLoop === true) this.script.stoppedByDoomLoop = true;
11594
11713
  if (output.error !== void 0 && this.script.errorDetail === void 0) {
11595
11714
  this.script.errorDetail = {
11596
11715
  message: output.error.message,
@@ -14582,6 +14701,7 @@ function isEmptyRound(result) {
14582
14701
  return (result.result ?? "").trim() === "";
14583
14702
  }
14584
14703
  function classifyRound(result, round, maxRounds, emptyStreak) {
14704
+ if (result.stoppedByDoomLoop === true) return "no_progress";
14585
14705
  if (result.stoppedAtIterationLimit !== true) return "done";
14586
14706
  if (isEmptyRound(result) && emptyStreak >= 1) return "no_progress";
14587
14707
  if (round >= maxRounds) return "step_limit";