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;
|
package/dist/agent/assistant.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|