clementine-agent 1.18.200 → 1.18.201
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/run-agent.js +30 -0
- package/dist/agent/side-effect-idempotency.d.ts +73 -0
- package/dist/agent/side-effect-idempotency.js +505 -0
- package/dist/channels/discord-utils.d.ts +1 -0
- package/dist/channels/discord-utils.js +14 -0
- package/dist/channels/discord.js +26 -0
- package/dist/channels/slack-utils.d.ts +1 -0
- package/dist/channels/slack-utils.js +14 -0
- package/dist/channels/slack.js +5 -0
- package/dist/channels/telegram.js +19 -0
- package/dist/gateway/router.d.ts +2 -0
- package/dist/gateway/router.js +19 -6
- package/package.json +1 -1
package/dist/agent/run-agent.js
CHANGED
|
@@ -97,6 +97,7 @@ export function invalidateMcpStatusEntry(name) {
|
|
|
97
97
|
import { BASE_DIR, PKG_DIR, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, normalizeClaudeSdkOptionsForOneMillionContext, TOOL_OUTPUT_GUARD, } from '../config.js';
|
|
98
98
|
import { buildGuardHooks } from './tool-output-guard.js';
|
|
99
99
|
import { buildDedupHook } from './tool-call-dedup.js';
|
|
100
|
+
import { buildSideEffectIdempotencyHook } from './side-effect-idempotency.js';
|
|
100
101
|
import { buildChatStopHook } from './chat-stop-hook.js';
|
|
101
102
|
import { buildAgentMap } from './agent-definitions.js';
|
|
102
103
|
import { buildExecutionToolPolicy, } from './execution-policy.js';
|
|
@@ -449,6 +450,25 @@ export async function runAgent(prompt, opts) {
|
|
|
449
450
|
});
|
|
450
451
|
},
|
|
451
452
|
});
|
|
453
|
+
// ── Side-effect idempotency hook (1.18.201) ────────────────────────
|
|
454
|
+
// Prevents exact duplicate high-confidence external mutations across
|
|
455
|
+
// context-overflow resumes/retries. This is intentionally narrow:
|
|
456
|
+
// confident email sends and CRM mutations only, keyed by stable identity
|
|
457
|
+
// fields. Unknown side effects remain event-log/audit data, not blocks.
|
|
458
|
+
const idempotency = buildSideEffectIdempotencyHook({
|
|
459
|
+
runId,
|
|
460
|
+
sessionKey: opts.sessionKey,
|
|
461
|
+
onDecision: (info) => {
|
|
462
|
+
if (info.decision !== 'block')
|
|
463
|
+
return;
|
|
464
|
+
writeEvent({
|
|
465
|
+
kind: 'error',
|
|
466
|
+
ts: new Date().toISOString(),
|
|
467
|
+
sessionId,
|
|
468
|
+
toolError: `_clementine_idempotency:block ${info.toolName} ${info.summary ?? ''}`.trim(),
|
|
469
|
+
});
|
|
470
|
+
},
|
|
471
|
+
});
|
|
452
472
|
// ── Chat persistence Stop hook (1.18.184, source='chat' only) ─────
|
|
453
473
|
// Keeps chat-initiated multi-step jobs running until they finish.
|
|
454
474
|
// Inspects the model's last assistant message for continuation
|
|
@@ -480,6 +500,10 @@ export async function runAgent(prompt, opts) {
|
|
|
480
500
|
// Merge hook maps from the modules. SDK accepts arrays of
|
|
481
501
|
// HookCallbackMatcher per event; we concatenate.
|
|
482
502
|
const mergedHooks = { ...guard.hooks };
|
|
503
|
+
for (const [evt, matchers] of Object.entries(idempotency.hooks)) {
|
|
504
|
+
const existing = mergedHooks[evt] ?? [];
|
|
505
|
+
mergedHooks[evt] = [...existing, ...matchers];
|
|
506
|
+
}
|
|
483
507
|
for (const [evt, matchers] of Object.entries(dedup.hooks)) {
|
|
484
508
|
const existing = mergedHooks[evt] ?? [];
|
|
485
509
|
mergedHooks[evt] = [...existing, ...matchers];
|
|
@@ -831,6 +855,12 @@ export async function runAgent(prompt, opts) {
|
|
|
831
855
|
warned: dedup.stats.warned,
|
|
832
856
|
blocked: dedup.stats.blocked,
|
|
833
857
|
} : undefined,
|
|
858
|
+
idempotency: idempotency.stats.guarded > 0 ? {
|
|
859
|
+
guarded: idempotency.stats.guarded,
|
|
860
|
+
blocked: idempotency.stats.blocked,
|
|
861
|
+
recorded: idempotency.stats.recorded,
|
|
862
|
+
failedNotRecorded: idempotency.stats.failedNotRecorded,
|
|
863
|
+
} : undefined,
|
|
834
864
|
}, 'runAgent: query complete');
|
|
835
865
|
// PRD §6 Phase 4e: subagent transcript backfill (Path C). The SDK persists
|
|
836
866
|
// every subagent's full message stream to ~/.claude/projects/<encoded-cwd>/
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-run idempotency guard for high-confidence external side effects.
|
|
3
|
+
*
|
|
4
|
+
* The classifier answers "is this mutating?". This module answers the
|
|
5
|
+
* narrower operational question: "is this the same external mutation we
|
|
6
|
+
* already saw succeed recently?" It intentionally fingerprints only calls
|
|
7
|
+
* with stable identity fields. Unknown or weakly-identified mutations are
|
|
8
|
+
* observed by the event log, not blocked.
|
|
9
|
+
*/
|
|
10
|
+
import type { HookCallbackMatcher, HookEvent } from '@anthropic-ai/claude-agent-sdk';
|
|
11
|
+
export type IdempotencyKind = 'email_send' | 'crm_mutation';
|
|
12
|
+
export interface SideEffectFingerprint {
|
|
13
|
+
kind: IdempotencyKind;
|
|
14
|
+
fingerprint: string;
|
|
15
|
+
ttlMs: number;
|
|
16
|
+
summary: string;
|
|
17
|
+
guidance: string;
|
|
18
|
+
details: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export interface SideEffectIdempotencyRecord {
|
|
21
|
+
version: 1;
|
|
22
|
+
ts: string;
|
|
23
|
+
runId: string;
|
|
24
|
+
sessionKey?: string;
|
|
25
|
+
toolName: string;
|
|
26
|
+
toolUseId?: string;
|
|
27
|
+
kind: IdempotencyKind;
|
|
28
|
+
fingerprint: string;
|
|
29
|
+
ttlMs: number;
|
|
30
|
+
summary: string;
|
|
31
|
+
details: Record<string, unknown>;
|
|
32
|
+
result: {
|
|
33
|
+
successReason: string;
|
|
34
|
+
statusCode?: number;
|
|
35
|
+
logId?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface SideEffectIdempotencyStats {
|
|
39
|
+
inspected: number;
|
|
40
|
+
guarded: number;
|
|
41
|
+
blocked: number;
|
|
42
|
+
recorded: number;
|
|
43
|
+
skipped: number;
|
|
44
|
+
failedNotRecorded: number;
|
|
45
|
+
}
|
|
46
|
+
export interface SideEffectIdempotencyHookOptions {
|
|
47
|
+
runId: string;
|
|
48
|
+
sessionKey?: string;
|
|
49
|
+
baseDir?: string;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
onDecision?: (info: {
|
|
52
|
+
decision: 'allow' | 'block' | 'record' | 'skip' | 'failed';
|
|
53
|
+
toolName: string;
|
|
54
|
+
kind?: IdempotencyKind;
|
|
55
|
+
fingerprint?: string;
|
|
56
|
+
summary?: string;
|
|
57
|
+
prior?: SideEffectIdempotencyRecord;
|
|
58
|
+
reason?: string;
|
|
59
|
+
}) => void;
|
|
60
|
+
}
|
|
61
|
+
export interface SideEffectIdempotencyHookHandles {
|
|
62
|
+
hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
|
|
63
|
+
stats: SideEffectIdempotencyStats;
|
|
64
|
+
}
|
|
65
|
+
export declare function buildSideEffectFingerprint(toolName: string, input: unknown): SideEffectFingerprint | null;
|
|
66
|
+
export declare function readRecentSideEffectRecords(baseDir?: string, now?: number): SideEffectIdempotencyRecord[];
|
|
67
|
+
export declare function appendSideEffectRecord(record: SideEffectIdempotencyRecord, baseDir?: string): void;
|
|
68
|
+
export declare function findPriorSuccessfulSideEffect(fingerprint: SideEffectFingerprint, opts?: {
|
|
69
|
+
baseDir?: string;
|
|
70
|
+
now?: number;
|
|
71
|
+
}): SideEffectIdempotencyRecord | null;
|
|
72
|
+
export declare function buildSideEffectIdempotencyHook(opts: SideEffectIdempotencyHookOptions): SideEffectIdempotencyHookHandles;
|
|
73
|
+
//# sourceMappingURL=side-effect-idempotency.d.ts.map
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-run idempotency guard for high-confidence external side effects.
|
|
3
|
+
*
|
|
4
|
+
* The classifier answers "is this mutating?". This module answers the
|
|
5
|
+
* narrower operational question: "is this the same external mutation we
|
|
6
|
+
* already saw succeed recently?" It intentionally fingerprints only calls
|
|
7
|
+
* with stable identity fields. Unknown or weakly-identified mutations are
|
|
8
|
+
* observed by the event log, not blocked.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import pino from 'pino';
|
|
14
|
+
import { BASE_DIR } from '../config.js';
|
|
15
|
+
import { isToolResultSuccessful, normalizedToolResultPayload } from './side-effect-classifier.js';
|
|
16
|
+
const logger = pino({ name: 'clementine.side-effect-idempotency' });
|
|
17
|
+
const EMAIL_SEND_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
const CRM_MUTATION_TTL_MS = 60 * 60 * 1000;
|
|
19
|
+
const MAX_STORE_BYTES = 2_000_000;
|
|
20
|
+
const MAX_STORE_LINES = 5000;
|
|
21
|
+
const EMAIL_ADDRESS_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
22
|
+
const EMAIL_BODY_KEYS = ['body', 'htmlBody', 'bodyHtml', 'html', 'text', 'plainText', 'message', 'content'];
|
|
23
|
+
const EMAIL_SUBJECT_KEYS = ['subject', 'title'];
|
|
24
|
+
const EMAIL_TO_KEYS = ['to', 'toEmail', 'to_email', 'recipient', 'recipients', 'toRecipients', 'to_recipients'];
|
|
25
|
+
const EMAIL_CC_KEYS = ['cc', 'ccRecipients', 'cc_recipients'];
|
|
26
|
+
const EMAIL_BCC_KEYS = ['bcc', 'bccRecipients', 'bcc_recipients'];
|
|
27
|
+
const CRM_PROVIDER_TOKENS = new Set([
|
|
28
|
+
'crm',
|
|
29
|
+
'salesforce',
|
|
30
|
+
'sfdc',
|
|
31
|
+
'hubspot',
|
|
32
|
+
'pipedrive',
|
|
33
|
+
'zoho',
|
|
34
|
+
'dynamics',
|
|
35
|
+
]);
|
|
36
|
+
const CRM_MUTATION_TOKENS = new Set(['create', 'update', 'upsert', 'delete', 'insert', 'set']);
|
|
37
|
+
const CRM_OBJECT_KEYS = ['object', 'objectName', 'object_name', 'sobject', 'sObject', 's_object', 'entity', 'module'];
|
|
38
|
+
const CRM_RECORD_ID_KEYS = ['recordId', 'record_id', 'id', 'contactId', 'contact_id', 'leadId', 'lead_id', 'accountId', 'account_id', 'externalId', 'external_id'];
|
|
39
|
+
const CRM_FIELD_KEYS = ['fields', 'values', 'data', 'properties', 'record', 'payload', 'input'];
|
|
40
|
+
function activeBaseDir(baseDir) {
|
|
41
|
+
return baseDir ?? process.env.CLEMENTINE_HOME ?? BASE_DIR;
|
|
42
|
+
}
|
|
43
|
+
function storePath(baseDir) {
|
|
44
|
+
return path.join(activeBaseDir(baseDir), 'idempotency', 'recent-side-effects.jsonl');
|
|
45
|
+
}
|
|
46
|
+
function stableJson(value) {
|
|
47
|
+
return JSON.stringify(value, (_key, v) => {
|
|
48
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const k of Object.keys(v).sort()) {
|
|
51
|
+
out[k] = v[k];
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
return v;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function sha256(value) {
|
|
59
|
+
return createHash('sha256').update(value).digest('hex');
|
|
60
|
+
}
|
|
61
|
+
function shortHash(value, chars = 20) {
|
|
62
|
+
return sha256(value).slice(0, chars);
|
|
63
|
+
}
|
|
64
|
+
function asRecord(value) {
|
|
65
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
66
|
+
}
|
|
67
|
+
function tokensForToolName(toolName) {
|
|
68
|
+
return toolName
|
|
69
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
70
|
+
.toLowerCase()
|
|
71
|
+
.split(/[^a-z0-9]+/)
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
function findFirstStringByKey(input, keys, depth = 0) {
|
|
75
|
+
if (depth > 4)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (!input || typeof input !== 'object')
|
|
78
|
+
return undefined;
|
|
79
|
+
const obj = input;
|
|
80
|
+
const lowerMap = new Map(Object.keys(obj).map((k) => [k.toLowerCase(), k]));
|
|
81
|
+
for (const key of keys) {
|
|
82
|
+
const actual = lowerMap.get(key.toLowerCase());
|
|
83
|
+
const value = actual ? obj[actual] : undefined;
|
|
84
|
+
if (typeof value === 'string' && value.trim())
|
|
85
|
+
return value.trim();
|
|
86
|
+
}
|
|
87
|
+
for (const value of Object.values(obj)) {
|
|
88
|
+
if (value && typeof value === 'object') {
|
|
89
|
+
const found = findFirstStringByKey(value, keys, depth + 1);
|
|
90
|
+
if (found)
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function findFirstValueByKey(input, keys, depth = 0) {
|
|
97
|
+
if (depth > 4)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (!input || typeof input !== 'object')
|
|
100
|
+
return undefined;
|
|
101
|
+
const obj = input;
|
|
102
|
+
const lowerMap = new Map(Object.keys(obj).map((k) => [k.toLowerCase(), k]));
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
const actual = lowerMap.get(key.toLowerCase());
|
|
105
|
+
if (actual && obj[actual] != null)
|
|
106
|
+
return obj[actual];
|
|
107
|
+
}
|
|
108
|
+
for (const value of Object.values(obj)) {
|
|
109
|
+
if (value && typeof value === 'object') {
|
|
110
|
+
const found = findFirstValueByKey(value, keys, depth + 1);
|
|
111
|
+
if (found !== undefined)
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
function collectEmails(value, out = new Set(), depth = 0) {
|
|
118
|
+
if (depth > 6 || value == null)
|
|
119
|
+
return out;
|
|
120
|
+
if (typeof value === 'string') {
|
|
121
|
+
const matches = value.match(EMAIL_ADDRESS_RE) ?? [];
|
|
122
|
+
for (const m of matches)
|
|
123
|
+
out.add(m.toLowerCase());
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(value)) {
|
|
127
|
+
for (const item of value)
|
|
128
|
+
collectEmails(item, out, depth + 1);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
if (typeof value === 'object') {
|
|
132
|
+
const obj = value;
|
|
133
|
+
for (const key of ['email', 'address', 'emailAddress']) {
|
|
134
|
+
if (key in obj)
|
|
135
|
+
collectEmails(obj[key], out, depth + 1);
|
|
136
|
+
}
|
|
137
|
+
for (const nested of Object.values(obj)) {
|
|
138
|
+
if (nested && typeof nested === 'object')
|
|
139
|
+
collectEmails(nested, out, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function emailsForKeys(input, keys) {
|
|
145
|
+
const value = findFirstValueByKey(input, keys);
|
|
146
|
+
return Array.from(collectEmails(value)).sort();
|
|
147
|
+
}
|
|
148
|
+
function normalizeWhitespace(value) {
|
|
149
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
150
|
+
}
|
|
151
|
+
function preview(value, max = 80) {
|
|
152
|
+
const normalized = normalizeWhitespace(value);
|
|
153
|
+
return normalized.length > max ? `${normalized.slice(0, max - 1)}...` : normalized;
|
|
154
|
+
}
|
|
155
|
+
function isEmailSendTool(toolName) {
|
|
156
|
+
const tokens = tokensForToolName(toolName);
|
|
157
|
+
const hasSend = tokens.includes('send') || /send_?email/i.test(toolName);
|
|
158
|
+
const hasEmailSurface = tokens.some((t) => t === 'email' || t === 'mail' || t === 'gmail' || t === 'outlook');
|
|
159
|
+
return hasSend && hasEmailSurface;
|
|
160
|
+
}
|
|
161
|
+
function buildEmailFingerprint(toolName, input) {
|
|
162
|
+
if (!isEmailSendTool(toolName))
|
|
163
|
+
return null;
|
|
164
|
+
const to = emailsForKeys(input, EMAIL_TO_KEYS);
|
|
165
|
+
const cc = emailsForKeys(input, EMAIL_CC_KEYS);
|
|
166
|
+
const bcc = emailsForKeys(input, EMAIL_BCC_KEYS);
|
|
167
|
+
const subject = findFirstStringByKey(input, EMAIL_SUBJECT_KEYS);
|
|
168
|
+
const body = findFirstStringByKey(input, EMAIL_BODY_KEYS);
|
|
169
|
+
if (to.length === 0 || !subject || !body)
|
|
170
|
+
return null;
|
|
171
|
+
const normalizedSubject = normalizeWhitespace(subject).toLowerCase();
|
|
172
|
+
// Collapse whitespace before hashing so harmless HTML/plain-text wrapping
|
|
173
|
+
// changes don't evade duplicate-send protection.
|
|
174
|
+
const bodyHash = shortHash(normalizeWhitespace(body));
|
|
175
|
+
const identity = {
|
|
176
|
+
kind: 'email_send',
|
|
177
|
+
to,
|
|
178
|
+
cc,
|
|
179
|
+
bcc,
|
|
180
|
+
subject: normalizedSubject,
|
|
181
|
+
bodyHash,
|
|
182
|
+
};
|
|
183
|
+
const recipientText = to.length <= 3 ? to.join(', ') : `${to.slice(0, 3).join(', ')} +${to.length - 3} more`;
|
|
184
|
+
return {
|
|
185
|
+
kind: 'email_send',
|
|
186
|
+
fingerprint: `email_send:${shortHash(stableJson(identity), 32)}`,
|
|
187
|
+
ttlMs: EMAIL_SEND_TTL_MS,
|
|
188
|
+
summary: `email send to ${recipientText} ("${preview(subject, 72)}")`,
|
|
189
|
+
guidance: `This email send to ${recipientText} was already accepted by the provider. Do not retry it. Continue with remaining follow-up work such as CRM stamping, task creation, or a concise status update.`,
|
|
190
|
+
details: {
|
|
191
|
+
to,
|
|
192
|
+
...(cc.length ? { cc } : {}),
|
|
193
|
+
...(bcc.length ? { bcc } : {}),
|
|
194
|
+
subject: normalizeWhitespace(subject),
|
|
195
|
+
bodyHash,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function isCrmMutationTool(toolName) {
|
|
200
|
+
const tokens = tokensForToolName(toolName);
|
|
201
|
+
return tokens.some((t) => CRM_PROVIDER_TOKENS.has(t))
|
|
202
|
+
&& tokens.some((t) => CRM_MUTATION_TOKENS.has(t));
|
|
203
|
+
}
|
|
204
|
+
function mutationVerb(toolName) {
|
|
205
|
+
return tokensForToolName(toolName).find((t) => CRM_MUTATION_TOKENS.has(t));
|
|
206
|
+
}
|
|
207
|
+
function pickCrmFields(input) {
|
|
208
|
+
const fromKnownKey = findFirstValueByKey(input, CRM_FIELD_KEYS);
|
|
209
|
+
if (fromKnownKey !== undefined)
|
|
210
|
+
return fromKnownKey;
|
|
211
|
+
const shallow = {};
|
|
212
|
+
for (const [k, v] of Object.entries(input)) {
|
|
213
|
+
if ([...CRM_OBJECT_KEYS, ...CRM_RECORD_ID_KEYS].some((known) => known.toLowerCase() === k.toLowerCase()))
|
|
214
|
+
continue;
|
|
215
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v == null)
|
|
216
|
+
shallow[k] = v;
|
|
217
|
+
}
|
|
218
|
+
return shallow;
|
|
219
|
+
}
|
|
220
|
+
function buildCrmFingerprint(toolName, input) {
|
|
221
|
+
if (!isCrmMutationTool(toolName))
|
|
222
|
+
return null;
|
|
223
|
+
const verb = mutationVerb(toolName);
|
|
224
|
+
const objectName = findFirstStringByKey(input, CRM_OBJECT_KEYS);
|
|
225
|
+
const recordId = findFirstStringByKey(input, CRM_RECORD_ID_KEYS);
|
|
226
|
+
const fields = pickCrmFields(input);
|
|
227
|
+
if (!verb || !objectName)
|
|
228
|
+
return null;
|
|
229
|
+
if (verb !== 'create' && !recordId)
|
|
230
|
+
return null;
|
|
231
|
+
const fieldsHash = shortHash(stableJson(fields ?? {}));
|
|
232
|
+
const identity = {
|
|
233
|
+
kind: 'crm_mutation',
|
|
234
|
+
verb,
|
|
235
|
+
objectName: objectName.toLowerCase(),
|
|
236
|
+
recordId: recordId?.toLowerCase() ?? null,
|
|
237
|
+
fieldsHash,
|
|
238
|
+
};
|
|
239
|
+
const target = `${objectName}${recordId ? ` ${recordId}` : ''}`;
|
|
240
|
+
return {
|
|
241
|
+
kind: 'crm_mutation',
|
|
242
|
+
fingerprint: `crm_mutation:${shortHash(stableJson(identity), 32)}`,
|
|
243
|
+
ttlMs: CRM_MUTATION_TTL_MS,
|
|
244
|
+
summary: `CRM ${verb} on ${target}`,
|
|
245
|
+
guidance: `This CRM ${verb} already succeeded for ${target}. Do not retry the same mutation. Continue with the remaining records or report completion/pending work.`,
|
|
246
|
+
details: {
|
|
247
|
+
verb,
|
|
248
|
+
objectName,
|
|
249
|
+
...(recordId ? { recordId } : {}),
|
|
250
|
+
fieldsHash,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function buildSfBashFingerprint(input) {
|
|
255
|
+
const command = typeof input.command === 'string' ? input.command.trim() : '';
|
|
256
|
+
// Narrow first pass: only Salesforce CLI data mutations with exact command
|
|
257
|
+
// identity. Known gaps to evaluate later: legacy
|
|
258
|
+
// `sfdx force:data:record:*`, `sf apex run`, direct REST/curl calls, and
|
|
259
|
+
// custom Python sender scripts. Those need per-pattern confidence before
|
|
260
|
+
// they can safely block across runs.
|
|
261
|
+
if (!/\b(?:sf|sfdx)\s+data\s+(?:update|delete|create|upsert)\b/i.test(command))
|
|
262
|
+
return null;
|
|
263
|
+
const normalizedCommand = normalizeWhitespace(command);
|
|
264
|
+
return {
|
|
265
|
+
kind: 'crm_mutation',
|
|
266
|
+
fingerprint: `crm_mutation:${shortHash(`bash:${normalizedCommand}`, 32)}`,
|
|
267
|
+
ttlMs: CRM_MUTATION_TTL_MS,
|
|
268
|
+
summary: `CRM CLI mutation (${preview(normalizedCommand, 88)})`,
|
|
269
|
+
guidance: `This CRM CLI mutation already succeeded recently. Do not retry the same command. Continue with remaining records or report completion/pending work.`,
|
|
270
|
+
details: {
|
|
271
|
+
verb: 'cli',
|
|
272
|
+
commandHash: shortHash(normalizedCommand),
|
|
273
|
+
commandPreview: preview(normalizedCommand, 160),
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
export function buildSideEffectFingerprint(toolName, input) {
|
|
278
|
+
const rec = asRecord(input);
|
|
279
|
+
if (!rec)
|
|
280
|
+
return null;
|
|
281
|
+
// Fingerprints intentionally omit agent identity. Idempotency is scoped to
|
|
282
|
+
// the external operation: if two agents try the same send/update, the
|
|
283
|
+
// second should continue the workflow instead of duplicating the effect.
|
|
284
|
+
if (toolName === 'Bash')
|
|
285
|
+
return buildSfBashFingerprint(rec);
|
|
286
|
+
return buildEmailFingerprint(toolName, rec) ?? buildCrmFingerprint(toolName, rec);
|
|
287
|
+
}
|
|
288
|
+
function parseRecord(line) {
|
|
289
|
+
try {
|
|
290
|
+
const raw = JSON.parse(line);
|
|
291
|
+
if (raw.version !== 1 || !raw.fingerprint || !raw.kind || !raw.ts || !raw.runId)
|
|
292
|
+
return null;
|
|
293
|
+
return raw;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export function readRecentSideEffectRecords(baseDir, now = Date.now()) {
|
|
300
|
+
const file = storePath(baseDir);
|
|
301
|
+
if (!existsSync(file))
|
|
302
|
+
return [];
|
|
303
|
+
try {
|
|
304
|
+
const lines = readFileSync(file, 'utf-8').split('\n').filter(Boolean);
|
|
305
|
+
const records = lines.map(parseRecord).filter((r) => r !== null);
|
|
306
|
+
return records.filter((r) => now - Date.parse(r.ts) <= r.ttlMs);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function maybePruneStore(baseDir, now = Date.now()) {
|
|
313
|
+
const file = storePath(baseDir);
|
|
314
|
+
if (!existsSync(file))
|
|
315
|
+
return;
|
|
316
|
+
try {
|
|
317
|
+
const st = statSync(file);
|
|
318
|
+
if (st.size <= MAX_STORE_BYTES)
|
|
319
|
+
return;
|
|
320
|
+
const recent = readRecentSideEffectRecords(baseDir, now).slice(-MAX_STORE_LINES);
|
|
321
|
+
writeFileSync(file, recent.map((r) => JSON.stringify(r)).join('\n') + (recent.length ? '\n' : ''));
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// non-critical
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
export function appendSideEffectRecord(record, baseDir) {
|
|
328
|
+
const file = storePath(baseDir);
|
|
329
|
+
try {
|
|
330
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
331
|
+
// Records are small JSONL rows; appendFileSync keeps each write atomic in
|
|
332
|
+
// practice for this size, and malformed lines are ignored on read.
|
|
333
|
+
appendFileSync(file, JSON.stringify(record) + '\n');
|
|
334
|
+
maybePruneStore(baseDir, Date.parse(record.ts));
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
logger.warn({ err, kind: record.kind, runId: record.runId }, 'Failed to append side-effect idempotency record');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
export function findPriorSuccessfulSideEffect(fingerprint, opts = {}) {
|
|
341
|
+
const now = opts.now ?? Date.now();
|
|
342
|
+
const records = readRecentSideEffectRecords(opts.baseDir, now);
|
|
343
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
344
|
+
const rec = records[i];
|
|
345
|
+
if (rec.fingerprint === fingerprint.fingerprint && now - Date.parse(rec.ts) <= fingerprint.ttlMs) {
|
|
346
|
+
return rec;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
function findLogId(value, depth = 0) {
|
|
352
|
+
if (depth > 5 || value == null)
|
|
353
|
+
return undefined;
|
|
354
|
+
if (typeof value !== 'object')
|
|
355
|
+
return undefined;
|
|
356
|
+
const obj = value;
|
|
357
|
+
for (const key of ['logId', 'log_id', 'requestId', 'request_id', 'id']) {
|
|
358
|
+
const raw = obj[key];
|
|
359
|
+
if (typeof raw === 'string' && raw.trim())
|
|
360
|
+
return raw.trim();
|
|
361
|
+
}
|
|
362
|
+
for (const nested of Object.values(obj)) {
|
|
363
|
+
const found = findLogId(nested, depth + 1);
|
|
364
|
+
if (found)
|
|
365
|
+
return found;
|
|
366
|
+
}
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
function duplicateReason(fingerprint, prior, now) {
|
|
370
|
+
const ageMinutes = Math.max(0, Math.round((now - Date.parse(prior.ts)) / 60_000));
|
|
371
|
+
return JSON.stringify({
|
|
372
|
+
successful: false,
|
|
373
|
+
blocked_by: 'idempotency_guard',
|
|
374
|
+
operation_already_succeeded: true,
|
|
375
|
+
error: 'duplicate-of-prior-call',
|
|
376
|
+
operation: fingerprint.summary,
|
|
377
|
+
prior_call: {
|
|
378
|
+
ts: prior.ts,
|
|
379
|
+
runId: prior.runId,
|
|
380
|
+
toolName: prior.toolName,
|
|
381
|
+
status_code: prior.result.statusCode,
|
|
382
|
+
log_id: prior.result.logId,
|
|
383
|
+
},
|
|
384
|
+
guidance: `${fingerprint.guidance} Prior success was ${ageMinutes} minute(s) ago.`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
export function buildSideEffectIdempotencyHook(opts) {
|
|
388
|
+
const stats = {
|
|
389
|
+
inspected: 0,
|
|
390
|
+
guarded: 0,
|
|
391
|
+
blocked: 0,
|
|
392
|
+
recorded: 0,
|
|
393
|
+
skipped: 0,
|
|
394
|
+
failedNotRecorded: 0,
|
|
395
|
+
};
|
|
396
|
+
const now = opts.now ?? (() => Date.now());
|
|
397
|
+
const preToolUse = async (input) => {
|
|
398
|
+
if (input.hook_event_name !== 'PreToolUse')
|
|
399
|
+
return {};
|
|
400
|
+
const evt = input;
|
|
401
|
+
const toolName = String(evt.tool_name ?? 'unknown');
|
|
402
|
+
stats.inspected += 1;
|
|
403
|
+
const fingerprint = buildSideEffectFingerprint(toolName, evt.tool_input);
|
|
404
|
+
if (!fingerprint) {
|
|
405
|
+
stats.skipped += 1;
|
|
406
|
+
opts.onDecision?.({ decision: 'skip', toolName, reason: 'no-confident-fingerprint' });
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
stats.guarded += 1;
|
|
410
|
+
const ts = now();
|
|
411
|
+
const prior = findPriorSuccessfulSideEffect(fingerprint, { baseDir: opts.baseDir, now: ts });
|
|
412
|
+
if (!prior) {
|
|
413
|
+
opts.onDecision?.({
|
|
414
|
+
decision: 'allow',
|
|
415
|
+
toolName,
|
|
416
|
+
kind: fingerprint.kind,
|
|
417
|
+
fingerprint: fingerprint.fingerprint,
|
|
418
|
+
summary: fingerprint.summary,
|
|
419
|
+
});
|
|
420
|
+
return {};
|
|
421
|
+
}
|
|
422
|
+
stats.blocked += 1;
|
|
423
|
+
logger.warn({
|
|
424
|
+
runId: opts.runId,
|
|
425
|
+
toolName,
|
|
426
|
+
kind: fingerprint.kind,
|
|
427
|
+
priorRunId: prior.runId,
|
|
428
|
+
summary: fingerprint.summary,
|
|
429
|
+
}, 'side-effect-idempotency: blocking duplicate successful side effect');
|
|
430
|
+
opts.onDecision?.({
|
|
431
|
+
decision: 'block',
|
|
432
|
+
toolName,
|
|
433
|
+
kind: fingerprint.kind,
|
|
434
|
+
fingerprint: fingerprint.fingerprint,
|
|
435
|
+
summary: fingerprint.summary,
|
|
436
|
+
prior,
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
hookSpecificOutput: {
|
|
440
|
+
hookEventName: 'PreToolUse',
|
|
441
|
+
permissionDecision: 'deny',
|
|
442
|
+
permissionDecisionReason: duplicateReason(fingerprint, prior, ts),
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
const postToolUse = async (input) => {
|
|
447
|
+
if (input.hook_event_name !== 'PostToolUse')
|
|
448
|
+
return {};
|
|
449
|
+
const evt = input;
|
|
450
|
+
const toolName = String(evt.tool_name ?? 'unknown');
|
|
451
|
+
const fingerprint = buildSideEffectFingerprint(toolName, evt.tool_input);
|
|
452
|
+
if (!fingerprint)
|
|
453
|
+
return {};
|
|
454
|
+
const result = isToolResultSuccessful(evt.tool_response, false);
|
|
455
|
+
if (!result.successful) {
|
|
456
|
+
stats.failedNotRecorded += 1;
|
|
457
|
+
opts.onDecision?.({
|
|
458
|
+
decision: 'failed',
|
|
459
|
+
toolName,
|
|
460
|
+
kind: fingerprint.kind,
|
|
461
|
+
fingerprint: fingerprint.fingerprint,
|
|
462
|
+
summary: fingerprint.summary,
|
|
463
|
+
reason: result.reason,
|
|
464
|
+
});
|
|
465
|
+
return {};
|
|
466
|
+
}
|
|
467
|
+
const payload = normalizedToolResultPayload(evt.tool_response);
|
|
468
|
+
const record = {
|
|
469
|
+
version: 1,
|
|
470
|
+
ts: new Date(now()).toISOString(),
|
|
471
|
+
runId: opts.runId,
|
|
472
|
+
...(opts.sessionKey ? { sessionKey: opts.sessionKey } : {}),
|
|
473
|
+
toolName,
|
|
474
|
+
toolUseId: evt.tool_use_id,
|
|
475
|
+
kind: fingerprint.kind,
|
|
476
|
+
fingerprint: fingerprint.fingerprint,
|
|
477
|
+
ttlMs: fingerprint.ttlMs,
|
|
478
|
+
summary: fingerprint.summary,
|
|
479
|
+
details: fingerprint.details,
|
|
480
|
+
result: {
|
|
481
|
+
successReason: result.reason,
|
|
482
|
+
...(result.statusCode !== undefined ? { statusCode: result.statusCode } : {}),
|
|
483
|
+
...(findLogId(payload) ? { logId: findLogId(payload) } : {}),
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
appendSideEffectRecord(record, opts.baseDir);
|
|
487
|
+
stats.recorded += 1;
|
|
488
|
+
opts.onDecision?.({
|
|
489
|
+
decision: 'record',
|
|
490
|
+
toolName,
|
|
491
|
+
kind: fingerprint.kind,
|
|
492
|
+
fingerprint: fingerprint.fingerprint,
|
|
493
|
+
summary: fingerprint.summary,
|
|
494
|
+
});
|
|
495
|
+
return {};
|
|
496
|
+
};
|
|
497
|
+
return {
|
|
498
|
+
hooks: {
|
|
499
|
+
PreToolUse: [{ hooks: [preToolUse] }],
|
|
500
|
+
PostToolUse: [{ hooks: [postToolUse] }],
|
|
501
|
+
},
|
|
502
|
+
stats,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
//# sourceMappingURL=side-effect-idempotency.js.map
|
|
@@ -45,6 +45,7 @@ export declare class DiscordStreamingMessage {
|
|
|
45
45
|
setToolStatus(status: string): void;
|
|
46
46
|
update(text: string): Promise<void>;
|
|
47
47
|
finalize(text: string): Promise<void>;
|
|
48
|
+
discard(): Promise<void>;
|
|
48
49
|
/** Format elapsed milliseconds as human-readable duration. */
|
|
49
50
|
private formatElapsed;
|
|
50
51
|
private flush;
|
|
@@ -270,6 +270,20 @@ export class DiscordStreamingMessage {
|
|
|
270
270
|
catch { /* best-effort */ }
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
+
async discard() {
|
|
274
|
+
this.isFinal = true;
|
|
275
|
+
if (this.flushTimer) {
|
|
276
|
+
clearTimeout(this.flushTimer);
|
|
277
|
+
this.flushTimer = null;
|
|
278
|
+
}
|
|
279
|
+
if (this.progressTimer) {
|
|
280
|
+
clearInterval(this.progressTimer);
|
|
281
|
+
this.progressTimer = null;
|
|
282
|
+
}
|
|
283
|
+
if (this.message) {
|
|
284
|
+
await this.message.delete().catch(() => { });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
273
287
|
/** Format elapsed milliseconds as human-readable duration. */
|
|
274
288
|
formatElapsed(ms) {
|
|
275
289
|
const s = Math.floor(ms / 1000);
|
package/dist/channels/discord.js
CHANGED
|
@@ -12,6 +12,7 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { chunkText, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
|
|
14
14
|
import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, OWNER_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, } from '../config.js';
|
|
15
|
+
import { isSilentGatewayResponse } from '../gateway/router.js';
|
|
15
16
|
import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
|
|
16
17
|
import { detectApprovalReply } from '../agent/local-turn.js';
|
|
17
18
|
import { normalizeToolsetName } from '../agent/toolsets.js';
|
|
@@ -776,6 +777,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
776
777
|
const streamer = new DiscordStreamingMessage(message.channel);
|
|
777
778
|
await streamer.start();
|
|
778
779
|
const response = await heartbeat.runManual();
|
|
780
|
+
if (isSilentGatewayResponse(response)) {
|
|
781
|
+
await streamer.discard();
|
|
782
|
+
updatePresence(sessionKey);
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
779
785
|
await streamer.finalize(response);
|
|
780
786
|
// Inject into DM session so follow-up conversation has context
|
|
781
787
|
gateway.injectContext(sessionKey, '!heartbeat', response);
|
|
@@ -832,6 +838,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
832
838
|
const streamer = new DiscordStreamingMessage(message.channel);
|
|
833
839
|
await streamer.start();
|
|
834
840
|
const response = await cronScheduler.runManual(jobName);
|
|
841
|
+
if (isSilentGatewayResponse(response)) {
|
|
842
|
+
await streamer.discard();
|
|
843
|
+
updatePresence(sessionKey);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
835
846
|
await streamer.finalize(response);
|
|
836
847
|
// Inject into DM session so follow-up conversation has context
|
|
837
848
|
gateway.injectContext(sessionKey, `!cron run ${jobName}`, response);
|
|
@@ -875,6 +886,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
875
886
|
const streamer = new DiscordStreamingMessage(message.channel);
|
|
876
887
|
await streamer.start();
|
|
877
888
|
const response = await cronScheduler.runWorkflow(wfName, inputs);
|
|
889
|
+
if (isSilentGatewayResponse(response)) {
|
|
890
|
+
await streamer.discard();
|
|
891
|
+
updatePresence(sessionKey);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
878
894
|
await streamer.finalize(response);
|
|
879
895
|
gateway.injectContext(sessionKey, `!workflow run ${wfName}`, response);
|
|
880
896
|
return;
|
|
@@ -1149,6 +1165,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
1149
1165
|
await streamer.start();
|
|
1150
1166
|
try {
|
|
1151
1167
|
const response = await gateway.handleMessage(sessionKey, effectiveText, (t) => streamer.update(t), oneOffModel, oneOffMaxTurns, (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); return Promise.resolve(); }, (status) => { streamer.setToolStatus(status); return Promise.resolve(); });
|
|
1168
|
+
if (isSilentGatewayResponse(response)) {
|
|
1169
|
+
await streamer.discard();
|
|
1170
|
+
updatePresence(sessionKey);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1152
1173
|
await streamer.finalize(response);
|
|
1153
1174
|
updatePresence(sessionKey);
|
|
1154
1175
|
// Track bot message for feedback reactions
|
|
@@ -1761,6 +1782,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
1761
1782
|
await streamer.start();
|
|
1762
1783
|
try {
|
|
1763
1784
|
const response = await gateway.handleMessage(sessionKey, agentMessage, (t) => streamer.update(t));
|
|
1785
|
+
if (isSilentGatewayResponse(response)) {
|
|
1786
|
+
await streamer.discard();
|
|
1787
|
+
updatePresence(sessionKey);
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1764
1790
|
await streamer.finalize(response);
|
|
1765
1791
|
}
|
|
1766
1792
|
catch (err) {
|
|
@@ -32,6 +32,7 @@ export declare class SlackStreamingMessage {
|
|
|
32
32
|
setToolStatus(status: string): void;
|
|
33
33
|
update(text: string): Promise<void>;
|
|
34
34
|
finalize(text: string): Promise<void>;
|
|
35
|
+
discard(): Promise<void>;
|
|
35
36
|
/** Format elapsed milliseconds as human-readable duration. */
|
|
36
37
|
private formatElapsed;
|
|
37
38
|
private flush;
|
|
@@ -131,6 +131,20 @@ export class SlackStreamingMessage {
|
|
|
131
131
|
await sendChunkedSlack(this.client, this.channel, text, this.threadTs);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
async discard() {
|
|
135
|
+
this.isFinal = true;
|
|
136
|
+
if (this.flushTimer) {
|
|
137
|
+
clearTimeout(this.flushTimer);
|
|
138
|
+
this.flushTimer = null;
|
|
139
|
+
}
|
|
140
|
+
if (this.progressTimer) {
|
|
141
|
+
clearInterval(this.progressTimer);
|
|
142
|
+
this.progressTimer = null;
|
|
143
|
+
}
|
|
144
|
+
if (this.ts) {
|
|
145
|
+
await this.client.chat.delete({ channel: this.channel, ts: this.ts }).catch(() => { });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
134
148
|
/** Format elapsed milliseconds as human-readable duration. */
|
|
135
149
|
formatElapsed(ms) {
|
|
136
150
|
const s = Math.floor(ms / 1000);
|
package/dist/channels/slack.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { App } from '@slack/bolt';
|
|
8
8
|
import pino from 'pino';
|
|
9
9
|
import { SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_OWNER_USER_ID, VAULT_DIR, } from '../config.js';
|
|
10
|
+
import { isSilentGatewayResponse } from '../gateway/router.js';
|
|
10
11
|
import { mdToSlack, sendChunkedSlack, SlackStreamingMessage } from './slack-utils.js';
|
|
11
12
|
import { friendlyToolName } from './discord-utils.js';
|
|
12
13
|
const logger = pino({ name: 'clementine.slack' });
|
|
@@ -124,6 +125,10 @@ export async function startSlack(gateway, dispatcher, slackBotManager) {
|
|
|
124
125
|
const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, // model
|
|
125
126
|
undefined, // maxTurns
|
|
126
127
|
async (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); }, async (status) => { streamer.setToolStatus(status); });
|
|
128
|
+
if (isSilentGatewayResponse(response)) {
|
|
129
|
+
await streamer.discard();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
127
132
|
await streamer.finalize(response);
|
|
128
133
|
// Track bot message for feedback reactions
|
|
129
134
|
if (streamer.messageTs) {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { Bot } from 'grammy';
|
|
8
8
|
import pino from 'pino';
|
|
9
9
|
import { TELEGRAM_BOT_TOKEN, TELEGRAM_OWNER_ID, } from '../config.js';
|
|
10
|
+
import { isSilentGatewayResponse } from '../gateway/router.js';
|
|
10
11
|
import { detectApprovalReply } from '../agent/local-turn.js';
|
|
11
12
|
const logger = pino({ name: 'clementine.telegram' });
|
|
12
13
|
const STREAM_UPDATE_INTERVAL = 1500; // ms
|
|
@@ -98,6 +99,12 @@ class TelegramStreamingMessage {
|
|
|
98
99
|
await sendChunked(this.bot, this.chatId, text);
|
|
99
100
|
}
|
|
100
101
|
}
|
|
102
|
+
async discard() {
|
|
103
|
+
this.isFinal = true;
|
|
104
|
+
if (this.messageId !== null) {
|
|
105
|
+
await this.bot.api.deleteMessage(this.chatId, this.messageId).catch(() => { });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
101
108
|
async flush() {
|
|
102
109
|
if (this.messageId === null || !this.pendingText || this.isFinal)
|
|
103
110
|
return;
|
|
@@ -154,6 +161,10 @@ export async function startTelegram(gateway, dispatcher) {
|
|
|
154
161
|
await streamer.start();
|
|
155
162
|
try {
|
|
156
163
|
const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, undefined, async (toolName) => { streamer.setStatus(`using ${toolName}...`); }, async (status) => { streamer.setStatus(status); });
|
|
164
|
+
if (isSilentGatewayResponse(response)) {
|
|
165
|
+
await streamer.discard();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
157
168
|
await streamer.finalize(response);
|
|
158
169
|
}
|
|
159
170
|
catch (err) {
|
|
@@ -180,6 +191,10 @@ export async function startTelegram(gateway, dispatcher) {
|
|
|
180
191
|
await streamer.start();
|
|
181
192
|
try {
|
|
182
193
|
const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, undefined, async (toolName) => { streamer.setStatus(`using ${toolName}...`); }, async (status) => { streamer.setStatus(status); });
|
|
194
|
+
if (isSilentGatewayResponse(response)) {
|
|
195
|
+
await streamer.discard();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
183
198
|
await streamer.finalize(response);
|
|
184
199
|
}
|
|
185
200
|
catch (err) {
|
|
@@ -209,6 +224,10 @@ export async function startTelegram(gateway, dispatcher) {
|
|
|
209
224
|
await streamer.start();
|
|
210
225
|
try {
|
|
211
226
|
const response = await gateway.handleMessage(sessionKey, text, (t) => streamer.update(t), undefined, undefined, async (toolName) => { streamer.setStatus(`using ${toolName}...`); }, async (status) => { streamer.setStatus(status); });
|
|
227
|
+
if (isSilentGatewayResponse(response)) {
|
|
228
|
+
await streamer.discard();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
212
231
|
await streamer.finalize(response);
|
|
213
232
|
}
|
|
214
233
|
catch (err) {
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export declare function runAgentResultIndicatesContextOverflow(result: {
|
|
|
35
35
|
text?: string;
|
|
36
36
|
}): boolean;
|
|
37
37
|
export type ChatErrorKind = 'rate_limit' | 'one_million_context' | 'context_overflow' | 'auth' | 'billing' | 'transient' | 'unknown';
|
|
38
|
+
export declare const SILENT_GATEWAY_RESPONSE = "__clementine_silent_response__";
|
|
39
|
+
export declare function isSilentGatewayResponse(text: string): boolean;
|
|
38
40
|
export declare function classifyChatError(err: unknown): ChatErrorKind;
|
|
39
41
|
/** Detect auth-like errors in response text that the SDK returned as "successful" results. */
|
|
40
42
|
export declare function looksLikeAuthError(text: string): boolean;
|
package/dist/gateway/router.js
CHANGED
|
@@ -190,6 +190,10 @@ export function runAgentResultIndicatesContextOverflow(result) {
|
|
|
190
190
|
return /^Autocompact is thrashing:\s*the context refilled to the limit/i.test(text)
|
|
191
191
|
|| /^rapid_refill_breaker\b/i.test(text);
|
|
192
192
|
}
|
|
193
|
+
export const SILENT_GATEWAY_RESPONSE = '__clementine_silent_response__';
|
|
194
|
+
export function isSilentGatewayResponse(text) {
|
|
195
|
+
return text === SILENT_GATEWAY_RESPONSE;
|
|
196
|
+
}
|
|
193
197
|
export function classifyChatError(err) {
|
|
194
198
|
const msg = String(err);
|
|
195
199
|
if (isCreditBalanceError(msg))
|
|
@@ -1671,7 +1675,9 @@ export class Gateway {
|
|
|
1671
1675
|
let aborted = false;
|
|
1672
1676
|
const ac = s?.abortController;
|
|
1673
1677
|
if (ac && !ac.signal.aborted) {
|
|
1674
|
-
|
|
1678
|
+
if (s)
|
|
1679
|
+
s.suppressAbortResponseFor = ac.signal;
|
|
1680
|
+
ac.abort('user-stop');
|
|
1675
1681
|
aborted = true;
|
|
1676
1682
|
}
|
|
1677
1683
|
if (s?.teamTaskControllers?.size) {
|
|
@@ -1700,6 +1706,7 @@ export class Gateway {
|
|
|
1700
1706
|
if (s.abortController && !s.abortController.signal.aborted) {
|
|
1701
1707
|
const partial = s.lastStreamedText ?? '';
|
|
1702
1708
|
s.pendingInterrupt = { partial, interruptedAt: Date.now() };
|
|
1709
|
+
s.suppressAbortResponseFor = s.abortController.signal;
|
|
1703
1710
|
logger.info({ sessionKey, partialLen: partial.length }, 'New message arrived — interrupting in-flight query');
|
|
1704
1711
|
// Pass a reason string so assistant.ts can distinguish this from a
|
|
1705
1712
|
// timeout abort and show the right final message.
|
|
@@ -1997,6 +2004,7 @@ export class Gateway {
|
|
|
1997
2004
|
if (recentContext.responseText) {
|
|
1998
2005
|
const current = this.sessions.get(sessionKey);
|
|
1999
2006
|
if (current?.abortController && !current.abortController.signal.aborted) {
|
|
2007
|
+
current.suppressAbortResponseFor = current.abortController.signal;
|
|
2000
2008
|
current.abortController.abort('replaced-by-recent-context');
|
|
2001
2009
|
logger.info({ sessionKey }, 'Interrupted active chat for recent operational context response');
|
|
2002
2010
|
}
|
|
@@ -2857,13 +2865,18 @@ export class Gateway {
|
|
|
2857
2865
|
clearTimeout(chatTimer);
|
|
2858
2866
|
if (hardWallTimer)
|
|
2859
2867
|
clearTimeout(hardWallTimer);
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2868
|
+
const cs = this.sessions.get(sessionKey);
|
|
2869
|
+
const suppressAbortResponse = cs?.suppressAbortResponseFor === chatAc.signal
|
|
2870
|
+
|| chatAc.signal.reason === 'interrupted-by-new-message'
|
|
2871
|
+
|| chatAc.signal.reason === 'replaced-by-recent-context'
|
|
2872
|
+
|| chatAc.signal.reason === 'user-stop';
|
|
2873
|
+
if (cs) {
|
|
2874
|
+
delete cs.abortController;
|
|
2875
|
+
if (cs.suppressAbortResponseFor === chatAc.signal)
|
|
2876
|
+
delete cs.suppressAbortResponseFor;
|
|
2864
2877
|
}
|
|
2865
2878
|
if (chatAc.signal.aborted) {
|
|
2866
|
-
return "Stopped. What would you like to do instead?";
|
|
2879
|
+
return suppressAbortResponse ? SILENT_GATEWAY_RESPONSE : "Stopped. What would you like to do instead?";
|
|
2867
2880
|
}
|
|
2868
2881
|
const errKind = classifyChatError(err);
|
|
2869
2882
|
logger.error({ err, sessionKey, errKind }, `Chat error (${errKind}) from ${sessionKey}`);
|