@visorcraft/idlehands 1.3.2 → 1.3.4

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
@@ -25,10 +25,12 @@ Idle Hands is built for people who want an agent that can actually ship work, no
25
25
 
26
26
  Trifecta is the integrated core of Idle Hands:
27
27
 
28
- - **Vault** → persistent memory + notes (`/vault`, `/note`, `/notes`)
28
+ - **Vault** → persistent memory + notes (`/vault`, `/note`, `/notes`) + automatic turn action summaries
29
29
  - **Replay** → file checkpoints and rewind/diff (`/checkpoints`, `/rewind`, `/diff`, `/undo`)
30
30
  - **Lens** → structural compression/indexing for better context usage
31
31
 
32
+ Every tool-using turn is automatically summarized and persisted to the Vault, so the model can recall what it did — even after context compaction drops earlier messages. This is especially useful for local models with limited context windows.
33
+
32
34
  Runtime controls:
33
35
 
34
36
  ```bash
@@ -222,6 +224,31 @@ idlehands health --scan-ports 8000-8100
222
224
  idlehands health --scan-ports 8080,8081,9000
223
225
  ```
224
226
 
227
+ ## Tool loop auto-continue
228
+
229
+ When the agent hits a critical tool loop (repeated identical tool calls with no progress), Idle Hands now automatically retries instead of stopping and waiting for a manual "Continue" message.
230
+
231
+ - **All surfaces**: TUI, Telegram bot, Discord bot, and Anton all support auto-continue
232
+ - **User notification**: each retry sends a visible notice with error details and attempt count
233
+ - **Configurable**: up to 5 retries by default (max 10), fully disableable
234
+
235
+ ```json
236
+ {
237
+ "tool_loop_auto_continue": {
238
+ "enabled": true,
239
+ "max_retries": 5
240
+ }
241
+ }
242
+ ```
243
+
244
+ On each retry the user sees:
245
+ > ⚠️ Tool loop detected: {error details}
246
+ > 🔄 Automatically continuing the task. (retry 1 of 5)
247
+
248
+ After exhausting all retries, the error surfaces normally. Anton handles this internally without orchestrator involvement.
249
+
250
+ ---
251
+
225
252
  ## Documentation map
226
253
 
227
254
  - [Getting Started](https://visorcraft.github.io/IdleHands/guide/getting-started)
package/dist/agent.js CHANGED
@@ -1879,6 +1879,26 @@ export async function createSession(opts) {
1879
1879
  };
1880
1880
  const finalizeAsk = async (text) => {
1881
1881
  const finalText = ensureInformativeAssistantText(text, { toolCalls, turns });
1882
+ // Auto-persist turn action summary to Vault so the model can recall what it did.
1883
+ if (vault && toolCalls > 0) {
1884
+ try {
1885
+ const actions = [];
1886
+ for (const [callId, name] of toolNameByCallId) {
1887
+ const args = toolArgsByCallId.get(callId) ?? {};
1888
+ actions.push(planModeSummary(name, args));
1889
+ }
1890
+ if (actions.length) {
1891
+ const userPromptSnippet = rawInstructionText.length > 120
1892
+ ? rawInstructionText.slice(0, 120) + '…'
1893
+ : rawInstructionText;
1894
+ const summary = `User asked: ${userPromptSnippet}\nActions (${actions.length} tool calls, ${turns} turns):\n${actions.map(a => `- ${a}`).join('\n')}\nResult: ${finalText.length > 200 ? finalText.slice(0, 200) + '…' : finalText}`;
1895
+ await vault.upsertNote(`turn_summary_${askId}`, summary, 'system');
1896
+ }
1897
+ }
1898
+ catch {
1899
+ // best-effort — never block ask completion for summary persistence
1900
+ }
1901
+ }
1882
1902
  await hookManager.emit('ask_end', { askId, text: finalText, turns, toolCalls });
1883
1903
  return { text: finalText, turns, toolCalls };
1884
1904
  };
@@ -2192,6 +2212,7 @@ export async function createSession(opts) {
2192
2212
  }
2193
2213
  }
2194
2214
  messages = compacted;
2215
+ let summaryUsed = false;
2195
2216
  if (dropped.length) {
2196
2217
  const droppedTokens = estimateTokensFromMessages(dropped);
2197
2218
  if (cfg.compact_summary !== false && droppedTokens > 200) {
@@ -2210,6 +2231,7 @@ export async function createSession(opts) {
2210
2231
  });
2211
2232
  const summary = resp.choices?.[0]?.message?.content ?? '';
2212
2233
  if (summary.trim()) {
2234
+ summaryUsed = true;
2213
2235
  messages.push({
2214
2236
  role: 'system',
2215
2237
  content: `[Compacted ${dropped.length} messages (~${droppedTokens} tokens). Progress summary:]\n${summary.trim()}\n[Continue from where you left off. Do not repeat completed work.]`,
@@ -2230,10 +2252,19 @@ export async function createSession(opts) {
2230
2252
  // Update token count AFTER injections so downstream reads are accurate
2231
2253
  currentContextTokens = estimateTokensFromMessages(messages);
2232
2254
  const afterTokens = estimateTokensFromMessages(compacted);
2255
+ const freedTokens = Math.max(0, beforeTokens - afterTokens);
2256
+ // Emit compaction event for callers (e.g. Anton controller → Discord)
2257
+ if (dropped.length) {
2258
+ try {
2259
+ await hookObj.onCompaction?.({ droppedMessages: dropped.length, freedTokens, summaryUsed });
2260
+ }
2261
+ catch { /* best effort */ }
2262
+ console.error(`[compaction] auto: dropped=${dropped.length} msgs, freed=~${freedTokens} tokens, summary=${summaryUsed}, remaining=${messages.length} msgs (~${currentContextTokens} tokens)`);
2263
+ }
2233
2264
  return {
2234
2265
  beforeMessages: beforeMsgs.length,
2235
2266
  afterMessages: compacted.length,
2236
- freedTokens: Math.max(0, beforeTokens - afterTokens),
2267
+ freedTokens,
2237
2268
  archivedToolMessages: dropped.filter((m) => m.role === 'tool').length,
2238
2269
  droppedMessages: dropped.length,
2239
2270
  dryRun: false,
@@ -2579,6 +2610,8 @@ export async function createSession(opts) {
2579
2610
  const warningKey = `${warning.level}:${warning.detector}:${detected.signature}`;
2580
2611
  if (!toolLoopWarningKeys.has(warningKey)) {
2581
2612
  toolLoopWarningKeys.add(warningKey);
2613
+ const argsSnippet = JSON.stringify(args).slice(0, 300);
2614
+ console.error(`[tool-loop] ${warning.level}: ${warning.toolName} (${warning.detector}, count=${warning.count}) args=${argsSnippet}`);
2582
2615
  await emitToolLoop({
2583
2616
  level: warning.level,
2584
2617
  detector: warning.detector,
@@ -2748,6 +2781,7 @@ export async function createSession(opts) {
2748
2781
  lastTurnSigs = turnSigs;
2749
2782
  if (shouldForceToollessRecovery) {
2750
2783
  if (!toollessRecoveryUsed) {
2784
+ console.error(`[tool-loop] Disabling tools for one recovery turn (turn=${turns})`);
2751
2785
  forceToollessRecoveryTurn = true;
2752
2786
  toollessRecoveryUsed = true;
2753
2787
  messages.push({
@@ -2768,6 +2802,7 @@ export async function createSession(opts) {
2768
2802
  });
2769
2803
  continue;
2770
2804
  }
2805
+ console.error(`[tool-loop] Recovery failed — model resumed looping after tools-disabled turn (turn=${turns})`);
2771
2806
  throw new AgentLoopBreak('critical tool-loop persisted after one tools-disabled recovery turn. Stopping to avoid infinite loop.');
2772
2807
  }
2773
2808
  const runOne = async (tc) => {