clementine-agent 1.0.54 → 1.0.56

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.
@@ -64,6 +64,8 @@ export declare class PersonalAssistant {
64
64
  private _lastTerminalReason?;
65
65
  /** Per-session stall nudge — set after a query shows stall signals, consumed on the next query. */
66
66
  private stallNudges;
67
+ /** Last contradiction finding per session, consumed by the session transcript writer to splice a correction note. */
68
+ private _lastContradictionFinding;
67
69
  private _compactedSessions;
68
70
  /** Last auto-matched project per session — exposed for CLI display. */
69
71
  private _lastMatchedProject;
@@ -21,6 +21,7 @@ import { agentWorkingMemoryFile, listAllGoals } from '../tools/shared.js';
21
21
  import { AgentManager } from './agent-manager.js';
22
22
  import { extractLinks } from './link-extractor.js';
23
23
  import { StallGuard } from './stall-guard.js';
24
+ import { collectToolCalls, detectContradiction, buildCorrectionPrompt } from './contradiction-validator.js';
24
25
  import { assembleContext } from '../memory/context-assembler.js';
25
26
  import { PromptCache } from './prompt-cache.js';
26
27
  import { searchSkills as searchSkillsSync } from './skill-extractor.js';
@@ -611,6 +612,8 @@ export class PersonalAssistant {
611
612
  _lastTerminalReason;
612
613
  /** Per-session stall nudge — set after a query shows stall signals, consumed on the next query. */
613
614
  stallNudges = new Map();
615
+ /** Last contradiction finding per session, consumed by the session transcript writer to splice a correction note. */
616
+ _lastContradictionFinding = new Map();
614
617
  _compactedSessions = new Set();
615
618
  /** Last auto-matched project per session — exposed for CLI display. */
616
619
  _lastMatchedProject = new Map();
@@ -1083,19 +1086,9 @@ If you're unsure what's happening first, run \`where_is_source\` — it reports
1083
1086
 
1084
1087
  ### Calling Claude Desktop connector tools (Drive, Gmail, etc.)
1085
1088
 
1086
- The **only source of truth for tool availability is your function schema**. Do not inspect \`claude-integrations.json\`, the inventory file, or run \`ToolSearch\` to "check" firstthose are telemetry caches, not reality.
1089
+ Just call the tool e.g. \`mcp__claude_ai_Google_Drive__search_files\`, \`mcp__claude_ai_Gmail__authenticate\`. Report the literal result: real data, auth error, whatever. Your replies are validated against actual tool results; claims that contradict a tool's return value are rejected and you're asked to retry. Don't pre-check with \`integration_status\` that's for env-var integrations, not schema-driven connectors.
1087
1090
 
1088
- **The right sequence when the user asks you to do something with a connector:**
1089
-
1090
- 1. **Just call the tool.** \`mcp__claude_ai_Google_Drive__search_files\`, \`mcp__claude_ai_Gmail__authenticate\`, etc. Attempt it. Report the literal result — real data, auth error, or whatever.
1091
- 2. **If refused** with "not in my function schema" / "tool not allowed," call \`allow_tool(exact_name)\` and retry. \`allow_tool\` auto-refreshes the inventory if the name is new — handles the case where the owner just added a connector at claude.ai.
1092
- 3. **If the owner says "I just added X at claude.ai"** or anything similar, call \`refresh_tool_inventory\` first to pick up the new connector. Report what came online.
1093
-
1094
- **Never** say the tool "isn't loaded in this session," "doesn't carry over from Claude Desktop," "the tools array is empty," or "MCP server still connecting." If any of those phrasings come to mind, call the tool directly and report what actually happens instead.
1095
-
1096
- \`list_allowed_tools\` / \`disallow_tool\` manage the whitelist. \`integration_status\` is for env-var (API key) integrations — **not** for claude_ai_* connectors, which are schema-driven. Don't use \`integration_status\` as a proxy for "can I call Drive / Gmail / etc." — those are always tried by direct tool call, not status lookup.
1097
-
1098
- **Critical rule: if the user asks you to use a claude_ai_* connector, you call the connector tool. Full stop.** Do not report "I tried and it failed" unless there was an actual tool call that returned an actual error — your audit log records every tool call, so narrating a failed attempt when the audit shows no call will be spotted.
1091
+ If a tool returns an argument error, fix the args and retry it's a per-call error, not a connector failure. \`allow_tool(name)\` + \`refresh_tool_inventory\` exist for the case where the owner just added a connector at claude.ai.
1099
1092
 
1100
1093
  ## Context Window Management
1101
1094
 
@@ -2050,6 +2043,18 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2050
2043
  if (key && this.memoryStore) {
2051
2044
  try {
2052
2045
  this.memoryStore.saveTurn(key, 'user', text);
2046
+ // Fix B: if a contradiction fired this turn, splice a system-role note
2047
+ // into the transcript BEFORE the assistant reply. Future turns that
2048
+ // read transcript history will see the correction and won't anchor on
2049
+ // the bad phrasing. Generic across all connectors; triggered only when
2050
+ // Fix A fired, so benign otherwise.
2051
+ const finding = this._lastContradictionFinding.get(key);
2052
+ if (finding) {
2053
+ this._lastContradictionFinding.delete(key);
2054
+ const note = `[system: Previous draft reply contained "${finding.matchedPhrase}" but ${finding.tool.name} ${finding.tool.resultClass === 'success' ? 'succeeded' : 'returned a per-call error (not a connector failure)'}. ` +
2055
+ `Corrected reply above is based on the actual tool result.]`;
2056
+ this.memoryStore.saveTurn(key, 'system', note);
2057
+ }
2053
2058
  this.memoryStore.saveTurn(key, 'assistant', responseText, model ?? MODEL);
2054
2059
  }
2055
2060
  catch (err) {
@@ -2135,6 +2140,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2135
2140
  logger.warn({ sessionKey, timeoutMs }, 'Chat query timed out');
2136
2141
  }, timeoutMs);
2137
2142
  }
2143
+ // One-shot flag so a contradiction retry can't chain into infinite loops.
2144
+ // Flipped true on the first intervention; subsequent replies go through
2145
+ // un-validated (but still logged).
2146
+ let contradictionRetried = false;
2138
2147
  try {
2139
2148
  for (let attempt = 0; attempt <= PersonalAssistant.RATE_LIMIT_MAX_RETRIES; attempt++) {
2140
2149
  const sdkOptions = this.buildOptions({ model, maxTurns: maxTurnsOverride ?? null, retrievalContext, profile, sessionKey, streaming: !!onText, verboseLevel, abortController, stallGuard, intentClassification, effort: intentClassification?.suggestedEffort });
@@ -2194,6 +2203,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2194
2203
  let staleSession = false;
2195
2204
  let contextRecovery = false;
2196
2205
  let lastAssistantBlocks = [];
2206
+ // Raw SDK messages for post-turn contradiction validation. We capture
2207
+ // assistant messages (tool_use blocks) and user messages (tool_result
2208
+ // blocks) so we can pair them and compare against the outgoing reply.
2209
+ const collectedSdkMessages = [];
2197
2210
  const queryStartMs = Date.now();
2198
2211
  // Event log: track query lifecycle
2199
2212
  const eventLog = getEventLog();
@@ -2204,6 +2217,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2204
2217
  const stream = query({ prompt, options: sdkOptions });
2205
2218
  let gotStreamEvents = false;
2206
2219
  for await (const message of stream) {
2220
+ // Capture assistant + user messages for post-turn contradiction
2221
+ // validation. Must happen before the switch below so we catch
2222
+ // every message type, including ones we don't otherwise handle.
2223
+ if (message.type === 'assistant' || message.type === 'user') {
2224
+ collectedSdkMessages.push({ type: message.type, message: message.message });
2225
+ }
2207
2226
  if (message.type === 'assistant') {
2208
2227
  const blocks = getContentBlocks(message);
2209
2228
  lastAssistantBlocks = blocks; // Track for fallback text extraction
@@ -2520,6 +2539,46 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2520
2539
  }
2521
2540
  }
2522
2541
  }
2542
+ // ── Contradiction validator ─────────────────────────────────────
2543
+ // If the model's reply claims a claude_ai_* connector is broken but
2544
+ // the audit log (this turn's tool_use/tool_result pairs) shows the
2545
+ // tool actually succeeded or returned a fixable arg error, reject the
2546
+ // reply and force one more turn with the literal tool result in hand.
2547
+ // Deterministic — does not rely on prompt obedience.
2548
+ if (!contradictionRetried && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && responseText.trim()) {
2549
+ try {
2550
+ const toolCallRecords = collectToolCalls(collectedSdkMessages);
2551
+ const finding = detectContradiction(responseText, toolCallRecords);
2552
+ if (finding) {
2553
+ contradictionRetried = true;
2554
+ logger.warn({
2555
+ sessionKey,
2556
+ tool: finding.tool.name,
2557
+ resultClass: finding.tool.resultClass,
2558
+ matchedPhrase: finding.matchedPhrase,
2559
+ }, 'Contradiction detected — rewriting reply');
2560
+ logAuditJsonl({
2561
+ event_type: 'confabulation_corrected',
2562
+ tool_name: finding.tool.name,
2563
+ result_class: finding.tool.resultClass,
2564
+ matched_phrase: finding.matchedPhrase,
2565
+ rejected_reply_preview: responseText.slice(0, 300),
2566
+ tool_result_preview: finding.tool.resultPreview,
2567
+ });
2568
+ // Hand the correction prompt to the SDK as the next user turn.
2569
+ // Resume the same session so the model keeps its context.
2570
+ prompt = buildCorrectionPrompt(finding);
2571
+ responseText = '';
2572
+ // Also record the contradiction for Fix B (session splice) later.
2573
+ this._lastContradictionFinding.set(sessionKey ?? '__no_session__', finding);
2574
+ continue;
2575
+ }
2576
+ }
2577
+ catch (err) {
2578
+ // Validator errors must never break the main reply path.
2579
+ logger.debug({ err }, 'Contradiction validator errored — passing reply through');
2580
+ }
2581
+ }
2523
2582
  // Event log: query completed successfully
2524
2583
  if (sessionKey) {
2525
2584
  eventLog.emitQueryEnd(sessionKey, {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Post-turn contradiction validator.
3
+ *
4
+ * After a chat turn's SDK stream completes, compares the assistant's outgoing
5
+ * reply against the actual tool_use/tool_result pairs from that turn. If a
6
+ * claude_ai_* connector succeeded (or returned an argument error — a fixable
7
+ * per-call failure) but the reply claims the connector is broken, missing from
8
+ * the schema, or otherwise generalizes a single failure into connector-level
9
+ * "deadness," we flag it.
10
+ *
11
+ * This is deterministic: it does NOT rely on the model obeying prompt rules.
12
+ * It's the load-bearing guardrail that replaces the forbidden-phrase list we
13
+ * used to patch into the system prompt.
14
+ */
15
+ export type ToolResultClass = 'success' | 'arg_error' | 'auth_error' | 'other_error';
16
+ export interface ToolCallRecord {
17
+ /** Tool name, e.g. mcp__claude_ai_Google_Drive__search_files */
18
+ name: string;
19
+ /** tool_use_id from the assistant's request */
20
+ id: string;
21
+ /** Classification of the paired tool_result */
22
+ resultClass: ToolResultClass;
23
+ /** First ~200 chars of the literal result content (or error text) */
24
+ resultPreview: string;
25
+ }
26
+ /** Regex matching reply phrasings that claim a connector-wide failure. */
27
+ export declare const CONTRADICTION_RE: RegExp;
28
+ export declare function classifyResult(content: string, isError: boolean): ToolResultClass;
29
+ /**
30
+ * Walk collected SDK messages (assistant + user) and pair every tool_use with
31
+ * its tool_result. Returns one record per tool_use; unpaired ones (still
32
+ * running at end of stream) are skipped.
33
+ */
34
+ export declare function collectToolCalls(messages: Array<{
35
+ type: string;
36
+ message?: any;
37
+ }>): ToolCallRecord[];
38
+ export interface ContradictionFinding {
39
+ /** The tool call whose result contradicts the reply */
40
+ tool: ToolCallRecord;
41
+ /** The exact phrase from the reply that triggered detection */
42
+ matchedPhrase: string;
43
+ }
44
+ /**
45
+ * Check a reply against a set of tool-call records. Returns the first
46
+ * contradiction found, or null if the reply is consistent with tool results.
47
+ *
48
+ * Contradiction = reply contains a CONTRADICTION_RE phrase AND at least one
49
+ * mcp__claude_ai_* tool in this turn classified `success` or `arg_error`.
50
+ * `auth_error` and `other_error` are legitimate failures that can support
51
+ * those reply phrasings.
52
+ */
53
+ export declare function detectContradiction(reply: string, calls: ToolCallRecord[]): ContradictionFinding | null;
54
+ /**
55
+ * Build the system-follow-up message we inject when a contradiction fires.
56
+ * The SDK will run one more turn with this as a user-role message (using
57
+ * `canUseTool` or similar hook), and the model's next reply replaces the
58
+ * bad one.
59
+ */
60
+ export declare function buildCorrectionPrompt(finding: ContradictionFinding): string;
61
+ //# sourceMappingURL=contradiction-validator.d.ts.map
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Post-turn contradiction validator.
3
+ *
4
+ * After a chat turn's SDK stream completes, compares the assistant's outgoing
5
+ * reply against the actual tool_use/tool_result pairs from that turn. If a
6
+ * claude_ai_* connector succeeded (or returned an argument error — a fixable
7
+ * per-call failure) but the reply claims the connector is broken, missing from
8
+ * the schema, or otherwise generalizes a single failure into connector-level
9
+ * "deadness," we flag it.
10
+ *
11
+ * This is deterministic: it does NOT rely on the model obeying prompt rules.
12
+ * It's the load-bearing guardrail that replaces the forbidden-phrase list we
13
+ * used to patch into the system prompt.
14
+ */
15
+ const ARG_ERROR_RE = /\b(invalid|unknown field|required|missing parameter|schema|unrecognized|unexpected property)\b/i;
16
+ const AUTH_ERROR_RE = /\b(unauthori[sz]ed|401|not authenticated|token expired|token has expired|invalid[_ ]?token|access denied)\b/i;
17
+ /** Regex matching reply phrasings that claim a connector-wide failure. */
18
+ export const CONTRADICTION_RE = /(dead\s*end|doesn'?t exist|not in (the |my )?schema|schema[- ]level|not available|isn'?t loaded|tools array is empty|MCP server still connecting|connector is (a )?dead|no such tool available|tool doesn't exist)/i;
19
+ export function classifyResult(content, isError) {
20
+ if (!isError)
21
+ return 'success';
22
+ if (ARG_ERROR_RE.test(content))
23
+ return 'arg_error';
24
+ if (AUTH_ERROR_RE.test(content))
25
+ return 'auth_error';
26
+ return 'other_error';
27
+ }
28
+ /** Extract string content from a tool_result block (which can be string or array of content blocks). */
29
+ function stringifyResultContent(content) {
30
+ if (typeof content === 'string')
31
+ return content;
32
+ if (Array.isArray(content)) {
33
+ return content
34
+ .map((b) => (typeof b === 'string' ? b : (b?.text ?? b?.content ?? JSON.stringify(b))))
35
+ .join('\n');
36
+ }
37
+ if (content == null)
38
+ return '';
39
+ try {
40
+ return JSON.stringify(content);
41
+ }
42
+ catch {
43
+ return String(content);
44
+ }
45
+ }
46
+ /**
47
+ * Walk collected SDK messages (assistant + user) and pair every tool_use with
48
+ * its tool_result. Returns one record per tool_use; unpaired ones (still
49
+ * running at end of stream) are skipped.
50
+ */
51
+ export function collectToolCalls(messages) {
52
+ const toolUses = new Map();
53
+ const results = new Map();
54
+ for (const msg of messages) {
55
+ if (msg.type === 'assistant' && msg.message?.content) {
56
+ const blocks = Array.isArray(msg.message.content) ? msg.message.content : [];
57
+ for (const b of blocks) {
58
+ if (b?.type === 'tool_use' && b.id && b.name) {
59
+ toolUses.set(b.id, { name: b.name, id: b.id });
60
+ }
61
+ }
62
+ }
63
+ else if (msg.type === 'user' && msg.message?.content) {
64
+ const blocks = Array.isArray(msg.message.content) ? msg.message.content : [];
65
+ for (const b of blocks) {
66
+ if (b?.type === 'tool_result' && b.tool_use_id) {
67
+ results.set(b.tool_use_id, {
68
+ content: stringifyResultContent(b.content),
69
+ isError: !!b.is_error,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ }
75
+ const records = [];
76
+ for (const [id, tu] of toolUses) {
77
+ const r = results.get(id);
78
+ if (!r)
79
+ continue;
80
+ records.push({
81
+ name: tu.name,
82
+ id,
83
+ resultClass: classifyResult(r.content, r.isError),
84
+ resultPreview: r.content.slice(0, 200),
85
+ });
86
+ }
87
+ return records;
88
+ }
89
+ /**
90
+ * Check a reply against a set of tool-call records. Returns the first
91
+ * contradiction found, or null if the reply is consistent with tool results.
92
+ *
93
+ * Contradiction = reply contains a CONTRADICTION_RE phrase AND at least one
94
+ * mcp__claude_ai_* tool in this turn classified `success` or `arg_error`.
95
+ * `auth_error` and `other_error` are legitimate failures that can support
96
+ * those reply phrasings.
97
+ */
98
+ export function detectContradiction(reply, calls) {
99
+ if (!reply)
100
+ return null;
101
+ const match = reply.match(CONTRADICTION_RE);
102
+ if (!match)
103
+ return null;
104
+ const connectorCalls = calls.filter(c => c.name.startsWith('mcp__claude_ai_'));
105
+ const recoverable = connectorCalls.find(c => c.resultClass === 'success' || c.resultClass === 'arg_error');
106
+ if (!recoverable)
107
+ return null;
108
+ return { tool: recoverable, matchedPhrase: match[0] };
109
+ }
110
+ /**
111
+ * Build the system-follow-up message we inject when a contradiction fires.
112
+ * The SDK will run one more turn with this as a user-role message (using
113
+ * `canUseTool` or similar hook), and the model's next reply replaces the
114
+ * bad one.
115
+ */
116
+ export function buildCorrectionPrompt(finding) {
117
+ const { tool, matchedPhrase } = finding;
118
+ const classLabel = tool.resultClass === 'success' ? 'returned successful content' :
119
+ tool.resultClass === 'arg_error' ? 'returned an argument error (fixable by correcting the args — the connector itself works)' :
120
+ tool.resultClass;
121
+ return (`Your previous reply contained "${matchedPhrase}" but ${tool.name} ${classLabel}.\n\n` +
122
+ `Literal tool result (first 200 chars):\n${tool.resultPreview}\n\n` +
123
+ `Rewrite your reply using the actual tool result. ` +
124
+ (tool.resultClass === 'arg_error'
125
+ ? `This was an argument error for one call — the connector is NOT broken. Re-read the tool's schema (the rejected argument names are in the error above), retry the call with correct args, and report what comes back.`
126
+ : `Do not generalize this to "the connector is broken" or "the tool doesn't exist" — those claims contradict the tool's actual return value.`));
127
+ }
128
+ //# sourceMappingURL=contradiction-validator.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",