clementine-agent 1.0.9 → 1.0.10

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.
@@ -1357,6 +1357,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1357
1357
  if (stallGuard) {
1358
1358
  const stallCheck = stallGuard.shouldBlockTool(toolName);
1359
1359
  if (stallCheck.block) {
1360
+ // When the breaker engages we also abort the whole query —
1361
+ // denying a single tool isn't enough for a runaway loop,
1362
+ // the agent will just try the next read-only tool.
1363
+ if (abortController && !abortController.signal.aborted) {
1364
+ logger.warn({ sessionKey, toolName }, 'StallGuard breaker engaged — aborting query');
1365
+ abortController.abort();
1366
+ }
1360
1367
  return { behavior: 'deny', message: stallCheck.message ?? 'Stall breaker.' };
1361
1368
  }
1362
1369
  }
@@ -2034,9 +2041,19 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2034
2041
  catch (e) {
2035
2042
  const errStr = String(e).toLowerCase();
2036
2043
  if (errStr.includes('abort') || errStr.includes('cancel')) {
2037
- // Query was aborted (timeout or user cancel) — return partial output
2038
- logger.warn({ sessionKey }, 'Chat query aborted');
2039
- if (!responseText) {
2044
+ // Query was aborted. Three sources: timeout, user cancel, or
2045
+ // StallGuard tripped (runaway loop detected).
2046
+ const stallAbort = !!stallGuard?.isBreakerActive();
2047
+ logger.warn({ sessionKey, stallAbort }, 'Chat query aborted');
2048
+ if (stallAbort) {
2049
+ const reason = stallGuard?.getBreakerReason() ?? 'runaway loop';
2050
+ const stallMsg = `I got stuck in a loop — ${reason} ` +
2051
+ `I stopped to save budget. Options:\n` +
2052
+ `• Rephrase your request more specifically\n` +
2053
+ `• Reply "deep mode" to queue this as a background task with a bigger budget`;
2054
+ responseText = responseText ? responseText + '\n\n' + stallMsg : stallMsg;
2055
+ }
2056
+ else if (!responseText) {
2040
2057
  responseText = 'I ran out of time on this one. Let me know if you want me to pick it back up.';
2041
2058
  }
2042
2059
  else {
@@ -2073,7 +2090,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2073
2090
  responseText = responseText || 'The conversation context filled up from large tool outputs. I\'ve reset the session — please try again, and I\'ll keep query results smaller this time.';
2074
2091
  }
2075
2092
  else if (errStr.includes('prompt is too long') || errStr.includes('prompt too long') || errStr.includes('context_length')) {
2076
- responseText = responseText || 'Error: prompt is too long context window overflow from large tool responses.';
2093
+ responseText = responseText || ('The conversation got too large to process (tool responses filled the context window). ' +
2094
+ "I've reset the session. Try again — I'll keep result sets smaller this time.");
2077
2095
  }
2078
2096
  else if (errStr.includes('no conversation found') || errStr.includes('conversation not found') || errStr.includes('session not found')) {
2079
2097
  // Stale session — clear and retry
@@ -2094,9 +2112,20 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2094
2112
  else {
2095
2113
  logger.error({ err: e, sessionKey }, 'SDK query failed');
2096
2114
  if (!responseText) {
2097
- // Surface a concise error description instead of a generic message
2115
+ // Classify so the user gets a useful suggestion instead of raw error text.
2098
2116
  const shortErr = String(e).replace(/\n.*$/s, '').slice(0, 200);
2099
- responseText = `Hit an error: ${shortErr}. Try again or \`!clear\` to reset the session.`;
2117
+ const lowerErr = String(e).toLowerCase();
2118
+ let hint = '';
2119
+ if (lowerErr.includes('econnrefused') || lowerErr.includes('socket') || lowerErr.includes('network')) {
2120
+ hint = 'Looks like a network issue — check your internet and try again.';
2121
+ }
2122
+ else if (lowerErr.includes('spawn') || lowerErr.includes('enoent')) {
2123
+ hint = 'A required binary seems to be missing. Try `clementine doctor` to diagnose.';
2124
+ }
2125
+ else {
2126
+ hint = 'Try again, or `!clear` to reset the session. If it keeps happening, check `~/.clementine/logs/clementine.log`.';
2127
+ }
2128
+ responseText = `I hit an error: ${shortErr}\n\n${hint}`;
2100
2129
  }
2101
2130
  }
2102
2131
  }
@@ -3644,7 +3673,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3644
3673
  appendProgress({ event: 'aborted', phase, reason: `${MAX_CONSECUTIVE_ERRORS} consecutive phase errors` });
3645
3674
  writeStatus({ jobName, status: 'error', phase, startedAt, finishedAt: new Date().toISOString() });
3646
3675
  logger.error(`Unleashed task ${jobName} aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors`);
3647
- const errorResult = lastOutput || `Task "${jobName}" aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive phase errors.`;
3676
+ const errorResult = lastOutput || (`Task "${jobName}" aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive phase errors. ` +
3677
+ `Check \`clementine cron runs ${jobName}\` for the failing phase, or retry with ` +
3678
+ `\`clementine cron run ${jobName}\`.`);
3648
3679
  if (this.onUnleashedComplete) {
3649
3680
  try {
3650
3681
  this.onUnleashedComplete(jobName, errorResult);
@@ -94,7 +94,22 @@ export class MetacognitiveMonitor {
94
94
  this.interventionCount++;
95
95
  return signal;
96
96
  }
97
- // Signal: excessive tool calls (>20 in a single execution)
97
+ // Signal: excessive tool calls with near-zero output.
98
+ // Warn at 20, intervene (hard stop) at 60 — beyond 60 the agent is
99
+ // almost certainly in a runaway loop that will burn through the
100
+ // budget cap with nothing to show for it.
101
+ if (this.toolCalls.length >= 60 && this.outputCharCount < 200) {
102
+ this.confidence = 'low';
103
+ if (!this.signals.includes('high_effort_low_output')) {
104
+ this.signals.push('high_effort_low_output');
105
+ }
106
+ this.interventionCount++;
107
+ return {
108
+ type: 'intervene',
109
+ reason: 'high_effort_low_output',
110
+ guidance: `You've made ${this.toolCalls.length} tool calls across ${this.uniqueTools.size} tools with only ${this.outputCharCount} chars of output. This is a runaway loop. Stopping now to prevent budget waste.`,
111
+ };
112
+ }
98
113
  if (this.toolCalls.length > 20 && this.outputCharCount < 200) {
99
114
  this.confidence = 'low';
100
115
  if (!this.signals.includes('high_effort_low_output')) {
@@ -33,6 +33,10 @@ export declare class StallGuard {
33
33
  block: boolean;
34
34
  message?: string;
35
35
  };
36
+ /** True when the stall breaker has been engaged during this query. */
37
+ isBreakerActive(): boolean;
38
+ /** Reason string set when the breaker engaged (empty if not active). */
39
+ getBreakerReason(): string;
36
40
  /**
37
41
  * Record a tool call. Runs loop detection and metacognition.
38
42
  * Activates the breaker if either detector fires.
@@ -41,6 +41,10 @@ export class StallGuard {
41
41
  }
42
42
  return { block: false };
43
43
  }
44
+ /** True when the stall breaker has been engaged during this query. */
45
+ isBreakerActive() { return this.breakerActive; }
46
+ /** Reason string set when the breaker engaged (empty if not active). */
47
+ getBreakerReason() { return this.breakerReason; }
44
48
  /**
45
49
  * Record a tool call. Runs loop detection and metacognition.
46
50
  * Activates the breaker if either detector fires.
@@ -1184,7 +1184,8 @@ export class CronScheduler {
1184
1184
  // Truncate
1185
1185
  if (msg.length > 300)
1186
1186
  msg = msg.slice(0, 297) + '...';
1187
- return `${jobName} failed: ${msg.trim()}`;
1187
+ return (`Cron \`${jobName}\` failed: ${msg.trim()}\n` +
1188
+ `Check \`clementine cron runs ${jobName}\` for details, or retry with \`clementine cron run ${jobName}\`.`);
1188
1189
  }
1189
1190
  listJobs() {
1190
1191
  if (this.jobs.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",