opencode-auto-resume 1.1.16 → 1.1.18

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 (3) hide show
  1. package/README.md +20 -0
  2. package/dist/index.js +165 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,20 @@ A separate **tool-call loop detector** catches the model calling the same tool 3
46
46
 
47
47
  Loop detection runs in two places. At idle, tool names are scanned from recent assistant messages. **Live**, every `tool.execute.before` hook fingerprints the call as `tool name + arguments` — so a subagent stuck re-reading the same file/range (even alternating between two near-identical argument sets, which never produces 3 consecutive identical calls) is caught after 6+ calls in the repeating cycle. On live detection the plugin aborts the running turn immediately (this is the sanctioned exception to the never-abort-busy rule: 6+ identical name+args fingerprints prove a hallucinated loop, and the current call has not started yet) and then sends `TOOL_LOOP_RECOVERY_PROMPT`. Esc-cancelled sessions are never touched.
48
48
 
49
+ ---
50
+
51
+ ### Unknown tool suggestion
52
+
53
+ When the model calls a tool that does not exist (a typo, a hallucinated name, or a tool from a different plugin that isn't loaded), OpenCode returns a `tool` part with `state.status = "error"`. If the same wrong tool name appears **2 times** in the session's message history, the plugin:
54
+
55
+ 1. Fetches the list of available tools via `ctx.client.tool.ids()` (cached for 5 minutes).
56
+ 2. Computes the closest match by Levenshtein distance (case-insensitive, threshold = half the wrong name's length).
57
+ 3. Sends a continue prompt that names the wrong tool, states it does not exist, suggests the closest match (if one was found), and lists the first 20 available tools for reference.
58
+
59
+ The suggestion fires once per busy cycle. A new user message resets the counter so the suggestion can fire again on a fresh cycle. Tool parts that already succeeded (`state.status = "completed"`) and parts for tools that do exist are skipped.
60
+
61
+ Threshold and cache are compile-time constants (`UNKNOWN_TOOL_THRESHOLD = 2`, `TOOL_IDS_CACHE_MS = 5 min`). The detection runs at idle, alongside the tool-text recovery check.
62
+
49
63
  _Motivated by:_
50
64
  - [#22142](https://github.com/anomalyco/opencode/issues/22142) — Repetitive tool-call loops with alibaba-coding-plan-cn/qwen3.6-plus
51
65
  - [#16218](https://github.com/anomalyco/opencode/issues/16218) — Model repeats the same response in a loop after generating an answer
@@ -203,6 +217,8 @@ The agent can call the built-in `task_complete` tool to signal that all work is
203
217
 
204
218
  If `task_complete` is called while open todos remain, the call is rejected (up to `maxRetries` times) with a message asking the agent to finish the remaining work first.
205
219
 
220
+ Repeat calls with no new user message in between are guarded: the first acknowledgement carries an explicit stop instruction, the second returns a repeat warning, and the third and subsequent calls are rejected as errors. This breaks the ack self-loop where the acknowledgement tool-result is fed back into the turn and a stuck model re-emits `task_complete` instead of ending with text. The counter resets on a genuine user message, on the block path above, or when any other tool runs in between.
221
+
206
222
  ---
207
223
 
208
224
  ### 🎉 emoji completion
@@ -312,6 +328,10 @@ session.status events:
312
328
 
313
329
  todo.updated events:
314
330
  └─ track real todo state (not regex on message text)
331
+ └─ if missing (no event received): idle path + task_complete
332
+ fetch on-demand via session.todo() API so the
333
+ open-todos reminder and the 🎉 completion latch see
334
+ real state, not a stale empty array.
315
335
 
316
336
  Timer loop (every 5s):
317
337
  for each busy session:
package/dist/index.js CHANGED
@@ -12363,7 +12363,8 @@ var DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS = [
12363
12363
  "streaming response failed",
12364
12364
  "stream.*fail",
12365
12365
  "connection.*reset",
12366
- "connection.*closed"
12366
+ "connection.*closed",
12367
+ "aborted due to timeout"
12367
12368
  ];
12368
12369
  var MAX_IDLE_SESSIONS = 50;
12369
12370
  var IDLE_CLEANUP_MS = 10 * 60000;
@@ -12371,7 +12372,12 @@ var SESSION_DISCOVERY_INTERVAL_MS = 60000;
12371
12372
  var TOOL_TEXT_RECOVERY_PROMPT = "Your last message contained a raw tool call printed as text instead of being executed. " + "Please use the proper tool calling mechanism to execute it.";
12372
12373
  var THINKING_TOOL_RECOVERY_PROMPT = "I noticed you have a tool call generated in your thinking/reasoning. " + "Please execute it using the proper tool calling mechanism instead of keeping it in reasoning.";
12373
12374
  var TOOL_LOOP_RECOVERY_PROMPT = "I notice you've been calling the same tool multiple times in a row without making progress. " + "Please step back and reassess your approach. Consider: " + "1) Are you stuck in a loop? 2) Do you need different information first? " + "3) Should you try a different tool or break the task into smaller steps? " + "Take a moment to think about what's blocking you and propose a different strategy.";
12375
+ var TASK_COMPLETE_ACK = "Task completion acknowledged. No further continuation will be sent. " + "End your turn now with a brief text reply \u2014 do not call task_complete or any other tool again " + "unless the user sends a new message.";
12376
+ var TASK_COMPLETE_REPEAT_WARNING = "Task completion acknowledged already on your previous call \u2014 completion is recorded. " + "End your turn now with a brief text reply. Do not call task_complete again; " + "further repeat calls are rejected as errors.";
12377
+ var TASK_COMPLETE_REPEAT_ERROR = "task_complete already acknowledged twice with no new user message or tool work since \u2014 " + "completion is recorded. End your turn with text and make no further tool calls.";
12374
12378
  var CTX_WRAPUP_TRIGGER = "ctx-wrapup";
12379
+ var UNKNOWN_TOOL_THRESHOLD = 2;
12380
+ var TOOL_IDS_CACHE_MS = 5 * 60000;
12375
12381
  var TOOL_TEXT_PATTERNS = [
12376
12382
  /<function\s*=/i,
12377
12383
  /<function>/i,
@@ -12466,6 +12472,42 @@ function containsWorkDescription(text) {
12466
12472
  return true;
12467
12473
  return false;
12468
12474
  }
12475
+ function levenshtein(a, b) {
12476
+ const m = a.length, n = b.length;
12477
+ if (m === 0)
12478
+ return n;
12479
+ if (n === 0)
12480
+ return m;
12481
+ let prev = new Array(n + 1);
12482
+ let curr = new Array(n + 1);
12483
+ for (let j = 0;j <= n; j++)
12484
+ prev[j] = j;
12485
+ for (let i = 1;i <= m; i++) {
12486
+ curr[0] = i;
12487
+ for (let j = 1;j <= n; j++) {
12488
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
12489
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
12490
+ }
12491
+ const tmp = prev;
12492
+ prev = curr;
12493
+ curr = tmp;
12494
+ }
12495
+ return prev[n];
12496
+ }
12497
+ function suggestClosestTool(wrongName, available) {
12498
+ const lower = wrongName.toLowerCase();
12499
+ let best = null;
12500
+ let bestDist = Infinity;
12501
+ for (const id of available) {
12502
+ const dist = levenshtein(lower, id.toLowerCase());
12503
+ const threshold = Math.max(2, Math.floor(lower.length / 2));
12504
+ if (dist < bestDist && dist <= threshold) {
12505
+ bestDist = dist;
12506
+ best = id;
12507
+ }
12508
+ }
12509
+ return best;
12510
+ }
12469
12511
  function isStreamingFailure(errorName, errorMessage, errorNames = DEFAULT_STREAMING_FAILURE_ERROR_NAMES, messagePatterns = DEFAULT_STREAMING_FAILURE_MESSAGE_PATTERNS) {
12470
12512
  if (!errorName && !errorMessage)
12471
12513
  return false;
@@ -12727,6 +12769,7 @@ var AutoResumePlugin = async (ctx, options) => {
12727
12769
  completionSignaled: false,
12728
12770
  todoNudgeAttempts: 0,
12729
12771
  taskCompleteOverrides: 0,
12772
+ taskCompleteSignals: 0,
12730
12773
  doneClaimNoTodosAttempts: 0,
12731
12774
  lastUserMessageAt: 0,
12732
12775
  pendingTools: 0,
@@ -12735,7 +12778,10 @@ var AutoResumePlugin = async (ctx, options) => {
12735
12778
  pendingRecoveryReason: null,
12736
12779
  pendingRecoveryAt: 0,
12737
12780
  recoveryAttempts: 0,
12738
- watchdogRetryGuard: false
12781
+ watchdogRetryGuard: false,
12782
+ unknownToolErrors: new Map,
12783
+ unknownToolSuggestionSent: false,
12784
+ checkedToolPartIDs: new Set
12739
12785
  };
12740
12786
  sessions.set(sid, w);
12741
12787
  }
@@ -13054,6 +13100,73 @@ var AutoResumePlugin = async (ctx, options) => {
13054
13100
  messagesInflight.set(sid, p);
13055
13101
  return p;
13056
13102
  }
13103
+ let cachedToolIds = null;
13104
+ let cachedToolIdsAt = 0;
13105
+ async function getAvailableToolIds() {
13106
+ if (cachedToolIds && Date.now() - cachedToolIdsAt < TOOL_IDS_CACHE_MS) {
13107
+ return cachedToolIds;
13108
+ }
13109
+ try {
13110
+ const result = await ctx.client.tool.ids();
13111
+ const ids = result.data ?? [];
13112
+ cachedToolIds = ids;
13113
+ cachedToolIdsAt = Date.now();
13114
+ return ids;
13115
+ } catch (e) {
13116
+ const msg = e instanceof Error ? e.message : String(e);
13117
+ await log("warn", `Failed to fetch tool IDs: ${msg}`);
13118
+ return cachedToolIds ?? [];
13119
+ }
13120
+ }
13121
+ async function checkForUnknownToolCalls(sid, w) {
13122
+ if (w.unknownToolSuggestionSent)
13123
+ return false;
13124
+ if (w.userCancelled || w.completionSignaled)
13125
+ return false;
13126
+ try {
13127
+ const available = await getAvailableToolIds();
13128
+ if (available.length === 0)
13129
+ return false;
13130
+ const messages = await getSessionMessages(sid);
13131
+ for (const msg of messages) {
13132
+ const parts = msg.parts;
13133
+ if (!parts)
13134
+ continue;
13135
+ for (const part of parts) {
13136
+ const partType = part.type;
13137
+ if (partType !== "tool")
13138
+ continue;
13139
+ const partId = part.id ?? part.callID ?? "";
13140
+ if (!partId || w.checkedToolPartIDs.has(partId))
13141
+ continue;
13142
+ w.checkedToolPartIDs.add(partId);
13143
+ const state = part.state;
13144
+ if (!state || state.status !== "error")
13145
+ continue;
13146
+ const toolName = part.tool ?? "";
13147
+ if (!toolName || available.includes(toolName))
13148
+ continue;
13149
+ const count = (w.unknownToolErrors.get(toolName) ?? 0) + 1;
13150
+ w.unknownToolErrors.set(toolName, count);
13151
+ if (count < UNKNOWN_TOOL_THRESHOLD)
13152
+ continue;
13153
+ const suggestion = suggestClosestTool(toolName, available);
13154
+ const toolList = available.slice(0, 20).join(", ");
13155
+ const prompt = suggestion ? `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `The closest matching tool is "${suggestion}". ` + `Please use "${suggestion}" instead and adjust your arguments accordingly. ` + `Available tools include: ${toolList}.` : `You tried to use the tool "${toolName}" ${count} times, but it does not exist. ` + `Please check the available tools and use the correct one. ` + `Available tools include: ${toolList}.`;
13156
+ w.unknownToolSuggestionSent = true;
13157
+ w.toolTextRecovered = true;
13158
+ await log("warn", `${short(sid)} - unknown tool "${toolName}" called ${count}x, suggesting "${suggestion ?? "(none)"}"`);
13159
+ await sendContinuePrompt(sid, prompt, w);
13160
+ return true;
13161
+ }
13162
+ }
13163
+ return false;
13164
+ } catch (e) {
13165
+ const msg = e instanceof Error ? e.message : String(e);
13166
+ await log("warn", `${short(sid)} - checkForUnknownToolCalls error: ${msg}`);
13167
+ return false;
13168
+ }
13169
+ }
13057
13170
  function roleOf(msg) {
13058
13171
  if (!msg)
13059
13172
  return;
@@ -13441,6 +13554,15 @@ var AutoResumePlugin = async (ctx, options) => {
13441
13554
  await log("info", `${short(sid)} - user recently active, skipping tool-text check`);
13442
13555
  return;
13443
13556
  }
13557
+ if ((w.todos || []).length === 0) {
13558
+ try {
13559
+ const fetched = await fetchSessionTodos(sid);
13560
+ if (fetched.length > 0)
13561
+ w.todos = fetched;
13562
+ } catch (e) {
13563
+ dbg(`checkForToolCallAsText sid=${short(sid)}: lazy todo fetch error: ${e}`);
13564
+ }
13565
+ }
13444
13566
  const recent = messages.slice(-3);
13445
13567
  let bestCandidate = null;
13446
13568
  let allAssistantText = "";
@@ -14283,6 +14405,9 @@ var AutoResumePlugin = async (ctx, options) => {
14283
14405
  w.status = "idle";
14284
14406
  resetIdleFlags(w);
14285
14407
  dbg(`session.idle sid=${short(sid)}: resetIdleFlags done, toolTextRecovered=${w.toolTextRecovered}, toolTextAttempts=${w.toolTextAttempts}, maxRetries=${maxRetries}`);
14408
+ if (!w.unknownToolSuggestionSent && !w.completionSignaled && !w.userCancelled) {
14409
+ checkForUnknownToolCalls(sid, w);
14410
+ }
14286
14411
  if (resumeOnActionIntent && !w.toolTextRecovered && !w.completionSignaled) {
14287
14412
  setTimeout(async () => {
14288
14413
  try {
@@ -14360,6 +14485,9 @@ var AutoResumePlugin = async (ctx, options) => {
14360
14485
  if (w) {
14361
14486
  w.doneClaimNoTodosAttempts = 0;
14362
14487
  w.lastUserMessageAt = Date.now();
14488
+ w.unknownToolErrors.clear();
14489
+ w.unknownToolSuggestionSent = false;
14490
+ w.checkedToolPartIDs.clear();
14363
14491
  }
14364
14492
  break;
14365
14493
  }
@@ -14452,17 +14580,41 @@ var AutoResumePlugin = async (ctx, options) => {
14452
14580
  }
14453
14581
  }
14454
14582
  const taskCompleteTool = tool({
14455
- description: "Signal that all work is complete and stop automatic continuation prompts. Call this ONLY after finishing everything requested.",
14583
+ description: "Signal that all work is complete and stop automatic continuation prompts. Call this ONLY after finishing everything requested. Call exactly once per completed round of work \u2014 repeat calls with no new user message in between are rejected.",
14456
14584
  args: {},
14457
14585
  execute: async (_args, ctx) => {
14458
14586
  const w = sessions.get(ctx.sessionID);
14459
14587
  if (w) {
14460
14588
  if (!w.isSubagent) {
14589
+ if ((w.todos || []).length === 0) {
14590
+ try {
14591
+ const fetched = await fetchSessionTodos(ctx.sessionID);
14592
+ if (fetched.length > 0)
14593
+ w.todos = fetched;
14594
+ } catch (e) {
14595
+ dbg(`task_complete sid=${short(ctx.sessionID)}: lazy todo fetch error: ${e}`);
14596
+ }
14597
+ }
14461
14598
  const openTodos = (w.todos || []).filter((t) => t.status === "pending" || t.status === "in_progress");
14462
14599
  if (openTodos.length > 0 && w.taskCompleteOverrides < maxRetries) {
14463
14600
  w.taskCompleteOverrides++;
14601
+ w.taskCompleteSignals = 0;
14602
+ const reminder = buildOpenTodosReminder(w.todos);
14603
+ const blockMsg = `Mark any finished todos complete and do not redo completed work.
14604
+ ${reminder}`;
14464
14605
  await log("info", `${short(ctx.sessionID)} - task_complete blocked: ${openTodos.length} open todos remain (override ${w.taskCompleteOverrides}/${maxRetries})`);
14465
- return `You have ${openTodos.length} unfinished task(s). Please complete all remaining work before signaling completion.`;
14606
+ let awaitingInput = false;
14607
+ try {
14608
+ awaitingInput = hasPendingUserInput(await getSessionMessages(ctx.sessionID));
14609
+ } catch (e) {
14610
+ dbg(`task_complete block: awaiting-input check error: ${e}`);
14611
+ }
14612
+ if (awaitingInput) {
14613
+ await log("info", `${short(ctx.sessionID)} - task_complete blocked but user input pending, skipping visible nudge`);
14614
+ } else {
14615
+ await sendContinuePrompt(ctx.sessionID, blockMsg, w);
14616
+ }
14617
+ return blockMsg;
14466
14618
  }
14467
14619
  }
14468
14620
  w.toolTextRecovered = true;
@@ -14472,8 +14624,13 @@ var AutoResumePlugin = async (ctx, options) => {
14472
14624
  w.toolTextTimer = null;
14473
14625
  }
14474
14626
  log("info", `${short(ctx.sessionID)} - task_complete called, ${w.isSubagent ? "subagent" : "agent"} done`);
14627
+ w.taskCompleteSignals++;
14628
+ if (w.taskCompleteSignals === 2)
14629
+ return TASK_COMPLETE_REPEAT_WARNING;
14630
+ if (w.taskCompleteSignals > 2)
14631
+ throw new Error(TASK_COMPLETE_REPEAT_ERROR);
14475
14632
  }
14476
- return "Task completion acknowledged. No further continuation will be sent.";
14633
+ return TASK_COMPLETE_ACK;
14477
14634
  }
14478
14635
  });
14479
14636
  return {
@@ -14505,6 +14662,7 @@ var AutoResumePlugin = async (ctx, options) => {
14505
14662
  if (w.userCancelled || w.completionSignaled) {
14506
14663
  w.userCancelled = false;
14507
14664
  w.completionSignaled = false;
14665
+ w.taskCompleteSignals = 0;
14508
14666
  await log("info", `${short(sid)} - new user message, re-arming auto-resume`);
14509
14667
  }
14510
14668
  },
@@ -14515,6 +14673,8 @@ var AutoResumePlugin = async (ctx, options) => {
14515
14673
  w.pendingTools++;
14516
14674
  w.lastActivityAt = Date.now();
14517
14675
  const toolName = input.tool ?? "unknown";
14676
+ if (toolName !== "task_complete")
14677
+ w.taskCompleteSignals = 0;
14518
14678
  const rawArgs = hookArgs?.args ?? input.args;
14519
14679
  w.liveToolSigs.push(toolCallSignature(toolName, rawArgs));
14520
14680
  if (w.liveToolSigs.length > 15)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auto-resume",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
4
4
  "description": "OpenCode plugin that automatically resumes stalled LLM sessions when thinking/streaming freezes mid-generation.",
5
5
  "keywords": [
6
6
  "opencode",