@visorcraft/idlehands 1.0.3 → 1.0.5

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/dist/agent.js CHANGED
@@ -178,6 +178,19 @@ function formatDurationMs(ms) {
178
178
  return '0.0s';
179
179
  return `${(ms / 1000).toFixed(1)}s`;
180
180
  }
181
+ function looksLikePlanningNarration(text, finishReason) {
182
+ const s = String(text ?? '').trim().toLowerCase();
183
+ if (!s)
184
+ return false;
185
+ // Incomplete streamed answer: likely still needs another turn.
186
+ if (finishReason === 'length')
187
+ return true;
188
+ // Strong completion cues: treat as final answer.
189
+ if (/(^|\n)\s*(done|completed|finished|final answer|summary:)\b/.test(s))
190
+ return false;
191
+ // Typical "thinking out loud"/plan chatter that should continue with tools.
192
+ return /\b(let me|i(?:'|’)ll|i will|i'm going to|i am going to|next i(?:'|’)ll|first i(?:'|’)ll|i need to|i should|checking|reviewing|exploring|starting by)\b/.test(s);
193
+ }
181
194
  function approxTokenCharCap(maxTokens) {
182
195
  const safe = Math.max(64, Math.floor(maxTokens));
183
196
  return safe * 4;
@@ -829,7 +842,7 @@ export async function createSession(opts) {
829
842
  const harnessVaultMode = harness.defaults?.trifecta?.vaultMode || 'off';
830
843
  const vaultMode = (cfg.trifecta?.vault?.mode || harnessVaultMode);
831
844
  const vaultEnabled = cfg.trifecta?.enabled !== false && cfg.trifecta?.vault?.enabled !== false;
832
- const activeVaultTools = vaultEnabled && vaultMode === 'active';
845
+ let activeVaultTools = vaultEnabled && vaultMode === 'active';
833
846
  const lensEnabled = cfg.trifecta?.enabled !== false && cfg.trifecta?.lens?.enabled !== false;
834
847
  const spawnTaskEnabled = opts.allowSpawnTask !== false && cfg.sub_agents?.enabled !== false;
835
848
  const mcpServers = Array.isArray(cfg.mcp?.servers) ? cfg.mcp.servers : [];
@@ -903,8 +916,18 @@ export async function createSession(opts) {
903
916
  }
904
917
  if (vaultEnabled && !opts.runtime?.vault) {
905
918
  await vault?.init().catch((e) => {
919
+ // If vault storage is unavailable (e.g., sandboxed FS / disk I/O),
920
+ // degrade gracefully by disabling active vault tools for this run.
921
+ activeVaultTools = false;
922
+ const msg = String(e?.message ?? e ?? 'unknown error');
923
+ const isDiskLike = /disk i\/o|sqlite|readonly|read-only|permission denied/i.test(msg);
906
924
  if (!process.env.IDLEHANDS_QUIET_WARNINGS) {
907
- console.warn(`[warn] vault init failed: ${e?.message ?? e}`);
925
+ if (isDiskLike) {
926
+ console.warn('[warn] vault disabled for this session (storage unavailable).');
927
+ }
928
+ else {
929
+ console.warn(`[warn] vault init failed: ${msg}`);
930
+ }
908
931
  }
909
932
  });
910
933
  }
@@ -1094,6 +1117,12 @@ export async function createSession(opts) {
1094
1117
  if (!task) {
1095
1118
  throw new Error('spawn_task: missing task');
1096
1119
  }
1120
+ // Prevent using delegation to bypass package-install confirmation restrictions.
1121
+ const taskSafety = checkExecSafety(task);
1122
+ if (!cfg.no_confirm && taskSafety.tier === 'cautious' && taskSafety.reason === 'package install/remove') {
1123
+ throw new Error('spawn_task: blocked — package install/remove is restricted in the current approval mode. ' +
1124
+ 'Do not delegate this to bypass confirmation requirements; ask the user to run with --no-confirm/--yolo instead.');
1125
+ }
1097
1126
  const defaults = cfg.sub_agents ?? {};
1098
1127
  const taskId = ++subTaskSeq;
1099
1128
  const emitStatus = options?.emitStatus ?? (() => { });
@@ -1732,6 +1761,12 @@ export async function createSession(opts) {
1732
1761
  const consecutiveCounts = new Map();
1733
1762
  let lastPassiveVaultQuery = '';
1734
1763
  let malformedCount = 0;
1764
+ let noProgressTurns = 0;
1765
+ const NO_PROGRESS_TURN_CAP = 3;
1766
+ let noToolTurns = 0;
1767
+ const NO_TOOL_REPROMPT_THRESHOLD = 2;
1768
+ let repromptUsed = false;
1769
+ let blockedPackageInstallAttempts = 0;
1735
1770
  const maybeInjectVaultContext = async () => {
1736
1771
  if (!vault || vaultMode !== 'passive')
1737
1772
  return;
@@ -1948,7 +1983,19 @@ export async function createSession(opts) {
1948
1983
  tgTokensPerSec: tgTps,
1949
1984
  health: healthSnapshot,
1950
1985
  };
1951
- const msg = resp.choices?.[0]?.message;
1986
+ const legacyChoice = resp?.role
1987
+ ? {
1988
+ finish_reason: resp?.finish_reason ?? 'stop',
1989
+ message: {
1990
+ role: resp?.role ?? 'assistant',
1991
+ content: resp?.content ?? '',
1992
+ tool_calls: resp?.tool_calls,
1993
+ },
1994
+ }
1995
+ : undefined;
1996
+ const choice0 = resp.choices?.[0] ?? legacyChoice;
1997
+ const finishReason = choice0?.finish_reason ?? 'unknown';
1998
+ const msg = choice0?.message;
1952
1999
  const content = msg?.content ?? '';
1953
2000
  // Conditionally strip thinking blocks based on harness config (§4i).
1954
2001
  // Non-reasoning models (thinking.strip === false) never emit <think> blocks,
@@ -1996,7 +2043,40 @@ export async function createSession(opts) {
1996
2043
  }
1997
2044
  }
1998
2045
  }
2046
+ if (cfg.verbose) {
2047
+ console.warn(`[turn ${turns}] finish_reason=${finishReason} content_chars=${content.length} visible_chars=${visible.length} tool_calls=${toolCallsArr?.length ?? 0}`);
2048
+ }
2049
+ const narration = (visible || content || '').trim();
2050
+ if ((!toolCallsArr || !toolCallsArr.length) && narration.length === 0) {
2051
+ noProgressTurns += 1;
2052
+ if (cfg.verbose) {
2053
+ console.warn(`[loop] no-progress turn ${noProgressTurns}/${NO_PROGRESS_TURN_CAP} (empty response)`);
2054
+ }
2055
+ if (noProgressTurns >= NO_PROGRESS_TURN_CAP) {
2056
+ throw new Error(`no progress for ${NO_PROGRESS_TURN_CAP} consecutive turns (empty responses with no tool calls). ` +
2057
+ `Likely malformed/empty model output loop; stopping early.`);
2058
+ }
2059
+ messages.push({
2060
+ role: 'user',
2061
+ content: '[system] Your previous response was empty (no text, no tool calls). Continue by either calling a tool with valid JSON arguments or giving a final answer.',
2062
+ });
2063
+ await hookObj.onTurnEnd?.({
2064
+ turn: turns,
2065
+ toolCalls,
2066
+ promptTokens: cumulativeUsage.prompt,
2067
+ completionTokens: cumulativeUsage.completion,
2068
+ promptTokensTurn,
2069
+ completionTokensTurn,
2070
+ ttftMs,
2071
+ ttcMs,
2072
+ ppTps,
2073
+ tgTps,
2074
+ });
2075
+ continue;
2076
+ }
2077
+ noProgressTurns = 0;
1999
2078
  if (toolCallsArr && toolCallsArr.length) {
2079
+ noToolTurns = 0;
2000
2080
  // Deduplicate ghost tool calls: if llama-server's XML parser splits one
2001
2081
  // tool call into two entries (one with full args, one empty/partial),
2002
2082
  // drop the empty one. Only removes entries where a richer version of the
@@ -2282,6 +2362,9 @@ export async function createSession(opts) {
2282
2362
  else if (builtInFn) {
2283
2363
  const value = await builtInFn(ctx, args);
2284
2364
  content = typeof value === 'string' ? value : JSON.stringify(value);
2365
+ if (name === 'exec') {
2366
+ blockedPackageInstallAttempts = 0;
2367
+ }
2285
2368
  }
2286
2369
  else if (isLspTool && lspManager) {
2287
2370
  // LSP tool dispatch
@@ -2383,6 +2466,16 @@ export async function createSession(opts) {
2383
2466
  if (e instanceof AgentLoopBreak)
2384
2467
  throw e;
2385
2468
  const msg = e?.message ?? String(e);
2469
+ // Fast-fail package-install bypass loops in non-yolo modes.
2470
+ // Applies to direct exec attempts and spawn_task delegation attempts.
2471
+ if ((tc.function.name === 'exec' || tc.function.name === 'spawn_task') &&
2472
+ /package install\/remove.*(?:blocked|restricted)|without --no-confirm\/--yolo/i.test(msg)) {
2473
+ blockedPackageInstallAttempts += 1;
2474
+ if (blockedPackageInstallAttempts >= 2) {
2475
+ throw new AgentLoopBreak(`${tc.function.name}: repeated blocked package-install attempts in current approval mode. ` +
2476
+ 'Do not retry or delegate this. Continue with a zero-dependency path, or ask the user to restart with --no-confirm/--yolo.');
2477
+ }
2478
+ }
2386
2479
  // Hook: onToolResult for errors (Phase 8.5)
2387
2480
  const callId = resolveCallId(tc);
2388
2481
  hookObj.onToolResult?.({ id: callId, name: tc.function.name, success: false, summary: msg || 'unknown error', result: `ERROR: ${msg || 'unknown error'}` });
@@ -2483,8 +2576,61 @@ export async function createSession(opts) {
2483
2576
  });
2484
2577
  continue;
2485
2578
  }
2579
+ const assistantText = visible || content || '';
2580
+ // Recovery fuse: if the model keeps narrating/planning without tool use,
2581
+ // nudge it once with the original task. Never resend more than once per ask().
2582
+ if (looksLikePlanningNarration(assistantText, finishReason)) {
2583
+ noToolTurns += 1;
2584
+ messages.push({ role: 'assistant', content: assistantText });
2585
+ if (noToolTurns >= NO_TOOL_REPROMPT_THRESHOLD) {
2586
+ if (!repromptUsed) {
2587
+ repromptUsed = true;
2588
+ noToolTurns = 0;
2589
+ const reminder = userContentToText(instruction).trim();
2590
+ const clippedReminder = reminder.length > 4000 ? `${reminder.slice(0, 4000)}\n[truncated]` : reminder;
2591
+ messages.push({
2592
+ role: 'user',
2593
+ content: `[system] You seem to be stuck narrating without using tools. Resume execution now.\n` +
2594
+ `Original task:\n${clippedReminder}\n\n` +
2595
+ `Call the needed tools directly. If everything is truly complete, provide the final answer.`
2596
+ });
2597
+ await hookObj.onTurnEnd?.({
2598
+ turn: turns,
2599
+ toolCalls,
2600
+ promptTokens: cumulativeUsage.prompt,
2601
+ completionTokens: cumulativeUsage.completion,
2602
+ promptTokensTurn,
2603
+ completionTokensTurn,
2604
+ ttftMs,
2605
+ ttcMs,
2606
+ ppTps,
2607
+ tgTps,
2608
+ });
2609
+ continue;
2610
+ }
2611
+ throw new Error(`no-tool loop detected: model produced planning/narration without tool calls for ${NO_TOOL_REPROMPT_THRESHOLD} turns even after one recovery reprompt`);
2612
+ }
2613
+ messages.push({
2614
+ role: 'user',
2615
+ content: '[system] Continue executing the task. Use tools now (do not just narrate plans). If complete, give the final answer.'
2616
+ });
2617
+ await hookObj.onTurnEnd?.({
2618
+ turn: turns,
2619
+ toolCalls,
2620
+ promptTokens: cumulativeUsage.prompt,
2621
+ completionTokens: cumulativeUsage.completion,
2622
+ promptTokensTurn,
2623
+ completionTokensTurn,
2624
+ ttftMs,
2625
+ ttcMs,
2626
+ ppTps,
2627
+ tgTps,
2628
+ });
2629
+ continue;
2630
+ }
2631
+ noToolTurns = 0;
2486
2632
  // final assistant message
2487
- messages.push({ role: 'assistant', content: visible || content || '' });
2633
+ messages.push({ role: 'assistant', content: assistantText });
2488
2634
  await hookObj.onTurnEnd?.({
2489
2635
  turn: turns,
2490
2636
  toolCalls,
@@ -2497,7 +2643,7 @@ export async function createSession(opts) {
2497
2643
  ppTps,
2498
2644
  tgTps,
2499
2645
  });
2500
- return { text: visible || content || '', turns, toolCalls };
2646
+ return { text: assistantText, turns, toolCalls };
2501
2647
  }
2502
2648
  const reason = `max iterations exceeded (${maxIters})`;
2503
2649
  throw new Error(reason);