clementine-agent 1.18.177 → 1.18.179
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/complex-task-detector.d.ts +19 -2
- package/dist/agent/complex-task-detector.js +23 -67
- package/dist/agent/run-agent-cron.js +63 -2
- package/dist/agent/run-agent.d.ts +7 -0
- package/dist/agent/run-agent.js +23 -1
- package/dist/cli/dashboard.js +14 -5
- package/dist/config.d.ts +19 -8
- package/dist/config.js +21 -4
- package/dist/gateway/router.d.ts +7 -1
- package/dist/gateway/router.js +129 -54
- package/dist/integrations/composio/client.d.ts +6 -0
- package/dist/integrations/composio/client.js +47 -1
- package/dist/integrations/composio/mcp-bridge.d.ts +1 -0
- package/dist/integrations/composio/mcp-bridge.js +17 -1
- package/package.json +1 -1
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit background-intent detector.
|
|
3
|
+
*
|
|
4
|
+
* Returns a recommendation ONLY when the user explicitly asks for background
|
|
5
|
+
* / autonomous / overnight execution. We deliberately do not classify "this
|
|
6
|
+
* looks complex" anymore — chat now stays in the live SDK loop, with
|
|
7
|
+
* automatic compaction and inline subagent delegation (Agent → planner /
|
|
8
|
+
* researcher / etc.) for context isolation, just like Claude Code itself.
|
|
9
|
+
* Big work that genuinely blows past the SDK's auto-compact is caught by the
|
|
10
|
+
* gateway's overflow → retry → promote-to-background fallback, which is the
|
|
11
|
+
* *real* escape hatch instead of a regex pre-classifier.
|
|
12
|
+
*
|
|
13
|
+
* The narrow detection here is what lets a user say "go research this
|
|
14
|
+
* overnight" and have it actually queue as a durable background task.
|
|
15
|
+
*/
|
|
1
16
|
export interface ComplexTaskRecommendation {
|
|
2
|
-
score: number;
|
|
3
17
|
reasons: string[];
|
|
4
18
|
suggestedMaxMinutes: number;
|
|
5
19
|
plan: string[];
|
|
6
|
-
|
|
20
|
+
/** Always true when this function returns a recommendation — the only
|
|
21
|
+
* trigger is the user explicitly asking for background execution. Kept
|
|
22
|
+
* on the type for back-compat with the post-overflow rescue path. */
|
|
23
|
+
queueImmediately: true;
|
|
7
24
|
}
|
|
8
25
|
export declare function detectComplexTaskForBackground(text: string): ComplexTaskRecommendation | null;
|
|
9
26
|
//# sourceMappingURL=complex-task-detector.d.ts.map
|
|
@@ -1,32 +1,18 @@
|
|
|
1
|
+
// Skill authoring is an interactive build-with-the-user flow; never auto-queue.
|
|
1
2
|
const SKILL_AUTHORING_RE = /\b(create|make|build|draft|write|teach|save|update)\b.{0,40}\b(skill|SKILL\.md)\b|\bskill[- ]creator\b/i;
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// The ONLY trigger. Matches "in the background", "overnight", "keep working",
|
|
4
|
+
// "don't stop", "autonomous", "long-running", "take your time", "deep mode".
|
|
5
|
+
const EXPLICIT_BACKGROUND_RE = /\b(background|deep mode|keep working|don't stop|dont stop|autonomous|long[- ]running|run overnight|overnight|take your time)\b/i;
|
|
6
|
+
// Light scope hints used only for the duration estimate + plan text. None of
|
|
7
|
+
// these alter whether the function fires — they shape the recommendation
|
|
8
|
+
// once the explicit-intent gate has already opened.
|
|
9
|
+
const BATCH_RE = /\b(all|every|each|bulk|batch|list of|contacts?|leads?|accounts?|tasks?|tickets?|records?|rows?|pages?|repos?|projects?|firms?|metros?|prospects?)\b/i;
|
|
10
|
+
const SIDE_EFFECT_RE = /\b(update|write|create|draft|send|post|comment|reply|upload|append|sync|mark|close|move|deploy|host|publish)\b/i;
|
|
7
11
|
const SYSTEM_KEYWORDS = [
|
|
8
|
-
'asana',
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'sheet',
|
|
13
|
-
'sheets',
|
|
14
|
-
'dataforseo',
|
|
15
|
-
'hubspot',
|
|
16
|
-
'notion',
|
|
17
|
-
'github',
|
|
18
|
-
'gmail',
|
|
19
|
-
'outlook',
|
|
20
|
-
'slack',
|
|
21
|
-
'discord',
|
|
22
|
-
'website',
|
|
23
|
-
'websites',
|
|
24
|
-
'crm',
|
|
25
|
-
'spreadsheet',
|
|
26
|
-
'csv',
|
|
27
|
-
'airtable',
|
|
28
|
-
'linear',
|
|
29
|
-
'jira',
|
|
12
|
+
'asana', 'salesforce', 'google sheet', 'google sheets', 'sheet', 'sheets',
|
|
13
|
+
'dataforseo', 'hubspot', 'notion', 'github', 'gmail', 'outlook', 'slack',
|
|
14
|
+
'discord', 'website', 'websites', 'crm', 'spreadsheet', 'csv', 'netlify',
|
|
15
|
+
'vercel', 'airtable', 'linear', 'jira',
|
|
30
16
|
];
|
|
31
17
|
function countSystemMentions(text) {
|
|
32
18
|
const lower = text.toLowerCase();
|
|
@@ -37,10 +23,10 @@ function countSystemMentions(text) {
|
|
|
37
23
|
}
|
|
38
24
|
return count;
|
|
39
25
|
}
|
|
40
|
-
function estimatedMinutes(
|
|
41
|
-
if (
|
|
26
|
+
function estimatedMinutes(systemCount, textLength) {
|
|
27
|
+
if (systemCount >= 4 || textLength > 800)
|
|
42
28
|
return 90;
|
|
43
|
-
if (
|
|
29
|
+
if (systemCount >= 2 || textLength > 400)
|
|
44
30
|
return 60;
|
|
45
31
|
return 30;
|
|
46
32
|
}
|
|
@@ -72,47 +58,17 @@ export function detectComplexTaskForBackground(text) {
|
|
|
72
58
|
return null;
|
|
73
59
|
if (SKILL_AUTHORING_RE.test(trimmed))
|
|
74
60
|
return null;
|
|
75
|
-
|
|
76
|
-
const reasons = [];
|
|
77
|
-
let score = 0;
|
|
78
|
-
if (EXPLICIT_BACKGROUND_RE.test(trimmed)) {
|
|
79
|
-
score += 4;
|
|
80
|
-
reasons.push('explicit background/deep-work wording');
|
|
81
|
-
}
|
|
82
|
-
if (COMPLEX_WORK_RE.test(trimmed)) {
|
|
83
|
-
score += 2;
|
|
84
|
-
reasons.push('multi-step work verb');
|
|
85
|
-
}
|
|
86
|
-
if (BATCH_RE.test(trimmed)) {
|
|
87
|
-
score += 2;
|
|
88
|
-
reasons.push('batch or many-record scope');
|
|
89
|
-
}
|
|
90
|
-
if (SIDE_EFFECT_RE.test(trimmed)) {
|
|
91
|
-
score += 1;
|
|
92
|
-
reasons.push('write/draft/update side effects');
|
|
93
|
-
}
|
|
94
|
-
if (MULTI_STEP_RE.test(trimmed)) {
|
|
95
|
-
score += 1;
|
|
96
|
-
reasons.push('multi-step sequencing');
|
|
97
|
-
}
|
|
98
|
-
if (systemCount >= 2) {
|
|
99
|
-
score += Math.min(4, systemCount);
|
|
100
|
-
reasons.push(`${systemCount} named systems or data surfaces`);
|
|
101
|
-
}
|
|
102
|
-
if (trimmed.length > 450) {
|
|
103
|
-
score += 1;
|
|
104
|
-
reasons.push('long detailed request');
|
|
105
|
-
}
|
|
106
|
-
const queueImmediately = EXPLICIT_BACKGROUND_RE.test(trimmed) && score >= 5;
|
|
107
|
-
const shouldOffer = queueImmediately || score >= 5 || (systemCount >= 2 && (BATCH_RE.test(trimmed) || SIDE_EFFECT_RE.test(trimmed)));
|
|
108
|
-
if (!shouldOffer)
|
|
61
|
+
if (!EXPLICIT_BACKGROUND_RE.test(trimmed))
|
|
109
62
|
return null;
|
|
63
|
+
const systemCount = countSystemMentions(trimmed);
|
|
64
|
+
const reasons = ['explicit background/deep-work wording'];
|
|
65
|
+
if (systemCount >= 2)
|
|
66
|
+
reasons.push(`${systemCount} named systems`);
|
|
110
67
|
return {
|
|
111
|
-
score,
|
|
112
68
|
reasons,
|
|
113
|
-
suggestedMaxMinutes: estimatedMinutes(
|
|
69
|
+
suggestedMaxMinutes: estimatedMinutes(systemCount, trimmed.length),
|
|
114
70
|
plan: buildPlan(trimmed, systemCount),
|
|
115
|
-
queueImmediately,
|
|
71
|
+
queueImmediately: true,
|
|
116
72
|
};
|
|
117
73
|
}
|
|
118
74
|
//# sourceMappingURL=complex-task-detector.js.map
|
|
@@ -26,6 +26,31 @@ const CRON_PROGRESS_PENDING_MAX_ITEMS = 20;
|
|
|
26
26
|
const CRON_PROGRESS_NOTES_MAX_CHARS = 2000;
|
|
27
27
|
const logger = pino({ name: 'clementine.run-agent-cron' });
|
|
28
28
|
const CRON_CONTEXT_ITEM_MAX = 80;
|
|
29
|
+
const CLEMENTINE_TOOLS_SERVER = `${(process.env.ASSISTANT_NAME ?? 'Clementine').toLowerCase()}-tools`;
|
|
30
|
+
const BACKGROUND_TASK_WORKER_NAME = 'background-task-worker';
|
|
31
|
+
const DEFAULT_BACKGROUND_WORKER_TOOLS = [
|
|
32
|
+
'Agent',
|
|
33
|
+
'Read',
|
|
34
|
+
'Write',
|
|
35
|
+
'Edit',
|
|
36
|
+
'Glob',
|
|
37
|
+
'Grep',
|
|
38
|
+
'Bash',
|
|
39
|
+
'WebSearch',
|
|
40
|
+
'WebFetch',
|
|
41
|
+
'TodoWrite',
|
|
42
|
+
];
|
|
43
|
+
const BACKGROUND_TASK_WORKER_PROMPT = [
|
|
44
|
+
'You are Clementine\'s background task worker for long-running user requests.',
|
|
45
|
+
'',
|
|
46
|
+
'Run the assigned task to completion using the available tools. Keep raw API responses, scraped pages, and large file contents out of the final answer; extract the fields you need and continue.',
|
|
47
|
+
'',
|
|
48
|
+
'Use TodoWrite for multi-step state. Process batch work in bounded chunks, checkpoint meaningful progress in durable artifacts when useful, and avoid repeating the same expensive read or tool call.',
|
|
49
|
+
'',
|
|
50
|
+
'If credentials, missing scope, human approval, or an irreversible action blocks completion, stop with one concise blocker/question and the exact next action needed. Do not keep retrying blindly.',
|
|
51
|
+
'',
|
|
52
|
+
'Return only the final user-facing result: links or changed locations, counts, skipped/error records, and the next recommended action.',
|
|
53
|
+
].join('\n');
|
|
29
54
|
/** Total number of skill blocks injected into a cron prompt — pinned + auto. */
|
|
30
55
|
const MAX_INJECTED_SKILLS = 4;
|
|
31
56
|
/**
|
|
@@ -172,6 +197,27 @@ function capContextBlock(s, max) {
|
|
|
172
197
|
return '';
|
|
173
198
|
return s.length <= max ? s : s.slice(0, max - 3) + '...';
|
|
174
199
|
}
|
|
200
|
+
function backgroundWorkerTools(effectiveAllowedTools, mcpServersApplied) {
|
|
201
|
+
if (effectiveAllowedTools)
|
|
202
|
+
return [...new Set(['Agent', ...effectiveAllowedTools])];
|
|
203
|
+
const mcpWildcards = [CLEMENTINE_TOOLS_SERVER, ...mcpServersApplied]
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.map((server) => `mcp__${server}__*`);
|
|
206
|
+
return [...new Set([...DEFAULT_BACKGROUND_WORKER_TOOLS, ...mcpWildcards])];
|
|
207
|
+
}
|
|
208
|
+
function buildBackgroundTaskWorker(tools, model, maxTurns) {
|
|
209
|
+
return {
|
|
210
|
+
description: [
|
|
211
|
+
'Use for background tasks queued from chat, especially multi-step work, batch data collection, project builds, deployments, and external-system writebacks.',
|
|
212
|
+
'This agent owns the heavy tool loop so the parent task context stays small.',
|
|
213
|
+
].join(' '),
|
|
214
|
+
prompt: BACKGROUND_TASK_WORKER_PROMPT,
|
|
215
|
+
tools,
|
|
216
|
+
...(model ? { model } : { model: 'sonnet' }),
|
|
217
|
+
effort: 'medium',
|
|
218
|
+
maxTurns: typeof maxTurns === 'number' && maxTurns > 0 ? maxTurns : 40,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
175
221
|
/**
|
|
176
222
|
* Build the previous-progress block from the cron progress JSON file.
|
|
177
223
|
* Lets the agent continue where the prior run left off without re-doing
|
|
@@ -749,10 +795,18 @@ export async function runAgentCron(opts) {
|
|
|
749
795
|
skillsMissing: plan.skillsMissing.length,
|
|
750
796
|
trickAllowedTools: effectiveAllowedTools?.length,
|
|
751
797
|
trickAllowedMcp: opts.allowedMcpServers?.length,
|
|
798
|
+
forcedBackgroundWorker: opts.jobName.startsWith('bg:'),
|
|
752
799
|
widenedFromSkills: plan.widenedFromSkills,
|
|
753
800
|
...(promptOversized ? { warning: 'prompt > 50KB; risk of "Prompt is too long" failure' } : {}),
|
|
754
801
|
}, 'runAgentCron: dispatching to runAgent');
|
|
755
802
|
const startedAt = Date.now();
|
|
803
|
+
const forceBackgroundWorker = opts.jobName.startsWith('bg:');
|
|
804
|
+
const workerTools = forceBackgroundWorker
|
|
805
|
+
? backgroundWorkerTools(effectiveAllowedTools, mcpServersApplied)
|
|
806
|
+
: [];
|
|
807
|
+
const workerDef = forceBackgroundWorker
|
|
808
|
+
? buildBackgroundTaskWorker(workerTools, opts.model, opts.maxTurns)
|
|
809
|
+
: null;
|
|
756
810
|
const result = await runAgent(builtPrompt, {
|
|
757
811
|
sessionKey: `cron:${opts.jobName}`,
|
|
758
812
|
source: 'cron',
|
|
@@ -762,9 +816,16 @@ export async function runAgentCron(opts) {
|
|
|
762
816
|
model: opts.model,
|
|
763
817
|
effort,
|
|
764
818
|
...(maxBudget !== undefined ? { maxBudgetUsd: maxBudget } : {}),
|
|
765
|
-
maxTurns: opts.maxTurns,
|
|
819
|
+
maxTurns: forceBackgroundWorker ? 5 : opts.maxTurns,
|
|
766
820
|
abortSignal: opts.abortSignal,
|
|
767
|
-
...(
|
|
821
|
+
...(forceBackgroundWorker
|
|
822
|
+
? {
|
|
823
|
+
allowedTools: ['Agent'],
|
|
824
|
+
permissionTools: workerTools,
|
|
825
|
+
forceSubagent: BACKGROUND_TASK_WORKER_NAME,
|
|
826
|
+
agents: { [BACKGROUND_TASK_WORKER_NAME]: workerDef },
|
|
827
|
+
}
|
|
828
|
+
: (effectiveAllowedTools ? { allowedTools: effectiveAllowedTools } : {})),
|
|
768
829
|
extraMcpServers: mcpServerMap,
|
|
769
830
|
// 1.18.121 — pipe the merged addDirs+pinned-skill folders to the SDK
|
|
770
831
|
// so a skill's bundled scripts/templates are reachable via Bash/Read
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* long-task preflight, NO mode=unleashed wrapper.
|
|
17
17
|
*/
|
|
18
18
|
import { type AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
+
import type { TerminalReason } from '../types.js';
|
|
19
20
|
/** Read the latest MCP status snapshot. Safe to call from any module. */
|
|
20
21
|
export declare function getLatestMcpStatusSnapshot(): {
|
|
21
22
|
servers: Array<{
|
|
@@ -26,6 +27,10 @@ export declare function getLatestMcpStatusSnapshot(): {
|
|
|
26
27
|
};
|
|
27
28
|
/** Write a fresh snapshot. Called from system/init handlers. */
|
|
28
29
|
export declare function recordMcpStatusFromSystemInit(rawMcpServers: unknown): void;
|
|
30
|
+
/** True when the SDK emits an internal context-pressure diagnostic as an
|
|
31
|
+
* assistant text block. These are operational warnings, not useful user
|
|
32
|
+
* output, and they can appear while the run is still recovering/continuing. */
|
|
33
|
+
export declare function isSdkContextDiagnosticText(text: string): boolean;
|
|
29
34
|
/** Drop one server from the cache so the next query repopulates it. */
|
|
30
35
|
export declare function invalidateMcpStatusEntry(name: string): {
|
|
31
36
|
cleared: boolean;
|
|
@@ -133,6 +138,8 @@ export interface RunAgentResult {
|
|
|
133
138
|
sessionId: string;
|
|
134
139
|
/** Final stop reason from the SDK (success, error_max_turns, error_max_budget_usd, etc). */
|
|
135
140
|
subtype: string;
|
|
141
|
+
/** Precise SDK loop terminal reason, when available. */
|
|
142
|
+
terminalReason?: TerminalReason;
|
|
136
143
|
/** Token usage breakdown (input, output, cache). */
|
|
137
144
|
usage?: {
|
|
138
145
|
input_tokens?: number;
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -75,6 +75,16 @@ function truncateForLog(value, maxBytes) {
|
|
|
75
75
|
return { _unstringifiable: true };
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
/** True when the SDK emits an internal context-pressure diagnostic as an
|
|
79
|
+
* assistant text block. These are operational warnings, not useful user
|
|
80
|
+
* output, and they can appear while the run is still recovering/continuing. */
|
|
81
|
+
export function isSdkContextDiagnosticText(text) {
|
|
82
|
+
const t = text.trim();
|
|
83
|
+
if (!t)
|
|
84
|
+
return false;
|
|
85
|
+
return /^Autocompact is thrashing:\s*the context refilled to the limit/i.test(t)
|
|
86
|
+
|| /^rapid_refill_breaker\b/i.test(t);
|
|
87
|
+
}
|
|
78
88
|
/** Drop one server from the cache so the next query repopulates it. */
|
|
79
89
|
export function invalidateMcpStatusEntry(name) {
|
|
80
90
|
const before = _lastMcpStatusSnapshot.servers.length;
|
|
@@ -483,6 +493,7 @@ export async function runAgent(prompt, opts) {
|
|
|
483
493
|
let totalCostUsd = 0;
|
|
484
494
|
let numTurns = 0;
|
|
485
495
|
let subtype = 'unknown';
|
|
496
|
+
let terminalReason;
|
|
486
497
|
let usage;
|
|
487
498
|
const stream = query({ prompt: effectivePrompt, options: sdkOptions });
|
|
488
499
|
try {
|
|
@@ -538,6 +549,15 @@ export async function runAgent(prompt, opts) {
|
|
|
538
549
|
const blocks = (am.message?.content ?? []);
|
|
539
550
|
for (const block of blocks) {
|
|
540
551
|
if (block.type === 'text' && typeof block.text === 'string') {
|
|
552
|
+
if (isSdkContextDiagnosticText(block.text)) {
|
|
553
|
+
logger.warn({
|
|
554
|
+
sessionKey: opts.sessionKey,
|
|
555
|
+
source,
|
|
556
|
+
subtype,
|
|
557
|
+
preview: block.text.slice(0, 240),
|
|
558
|
+
}, 'runAgent: suppressed SDK context diagnostic text');
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
541
561
|
finalText += block.text;
|
|
542
562
|
// PRD Phase 4a / 1.18.85: llm_text Event. Truncate at 8KB to keep
|
|
543
563
|
// the JSONL light — full text is reachable via the SDK transcript.
|
|
@@ -611,6 +631,7 @@ export async function runAgent(prompt, opts) {
|
|
|
611
631
|
const result = message;
|
|
612
632
|
sessionId = sessionId || (result.session_id ?? '');
|
|
613
633
|
subtype = result.subtype ?? 'unknown';
|
|
634
|
+
terminalReason = result.terminal_reason;
|
|
614
635
|
numTurns = result.num_turns ?? numTurns;
|
|
615
636
|
totalCostUsd = result.total_cost_usd ?? 0;
|
|
616
637
|
const u = result.usage;
|
|
@@ -629,7 +650,7 @@ export async function runAgent(prompt, opts) {
|
|
|
629
650
|
ts: new Date().toISOString(),
|
|
630
651
|
sessionId,
|
|
631
652
|
costUsd: totalCostUsd,
|
|
632
|
-
stopReason: subtype,
|
|
653
|
+
stopReason: terminalReason && terminalReason !== 'completed' ? `${subtype}:${terminalReason}` : subtype,
|
|
633
654
|
});
|
|
634
655
|
// PRD Phase 4d / 1.18.101: unregister from the hook-session registry.
|
|
635
656
|
// Late-arriving hook events for this sessionId silently drop after this.
|
|
@@ -758,6 +779,7 @@ export async function runAgent(prompt, opts) {
|
|
|
758
779
|
numTurns,
|
|
759
780
|
sessionId,
|
|
760
781
|
subtype,
|
|
782
|
+
...(terminalReason ? { terminalReason } : {}),
|
|
761
783
|
...(usage ? { usage } : {}),
|
|
762
784
|
runId,
|
|
763
785
|
permissionMode: toolPolicy.permissionMode,
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -19,7 +19,7 @@ import { TunnelManager } from './tunnel.js';
|
|
|
19
19
|
import { AgentManager } from '../agent/agent-manager.js';
|
|
20
20
|
import { discoverMcpServers, getClaudeIntegrations, KNOWN_MCP_DESCRIPTIONS } from '../agent/mcp-bridge.js';
|
|
21
21
|
import { buildBuilderEnrichedMessage, builderSessionKey } from '../dashboard/builder/prompt.js';
|
|
22
|
-
import { AGENTS_DIR, MEMORY_FILE, SESSIONS_FILE, TIMEZONE, applyOneMillionContextRecovery, currentTimeZone, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
|
|
22
|
+
import { AGENTS_DIR, MEMORY_FILE, SESSIONS_FILE, TIMEZONE, applyOneMillionContextRecovery, currentTimeZone, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, setEnvOverride, } from '../config.js';
|
|
23
23
|
import { parseTasks } from '../tools/shared.js';
|
|
24
24
|
// 1.18.160 — also pull parseCronJobs + parseAgentCronJobs so getCronJobs()
|
|
25
25
|
// returns the same merged set the runtime fires (CRON.md + agent CRON +
|
|
@@ -8724,6 +8724,11 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
8724
8724
|
content = content.trimEnd() + `\n${key}=${value}\n`;
|
|
8725
8725
|
}
|
|
8726
8726
|
writeFileSync(ENV_PATH, content, { mode: 0o600 });
|
|
8727
|
+
// Always mirror the disk write into the live env cache. Without this,
|
|
8728
|
+
// BUDGET.* and any other getEnv-backed config stays at the value it
|
|
8729
|
+
// was first read with — that's how "Saved $0 in the dashboard" can
|
|
8730
|
+
// coexist with "Hit the $1.00 cron budget cap" in the same minute.
|
|
8731
|
+
setEnvOverride(key, value);
|
|
8727
8732
|
}
|
|
8728
8733
|
function deleteEnvValue(key) {
|
|
8729
8734
|
if (!existsSync(ENV_PATH))
|
|
@@ -8731,6 +8736,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
8731
8736
|
const re = new RegExp(`^${key}=.*\n?`, 'm');
|
|
8732
8737
|
const content = readFileSync(ENV_PATH, 'utf-8').replace(re, '');
|
|
8733
8738
|
writeFileSync(ENV_PATH, content, { mode: 0o600 });
|
|
8739
|
+
// Mirror the delete so live readers don't keep seeing the cached value.
|
|
8740
|
+
setEnvOverride(key, '');
|
|
8741
|
+
delete process.env[key];
|
|
8734
8742
|
}
|
|
8735
8743
|
const DASHBOARD_BUDGET_ROWS = [
|
|
8736
8744
|
{ key: 'BUDGET_CHAT_USD', value: '5', label: 'Chat', hint: 'Per interactive chat turn' },
|
|
@@ -8786,8 +8794,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
8786
8794
|
return { ok: false, error: 'Budget cap is too high for the dashboard. Use the CLI if you really need a cap above $1000.' };
|
|
8787
8795
|
}
|
|
8788
8796
|
const normalized = n === 0 ? '0' : String(Math.round(n * 100) / 100);
|
|
8797
|
+
// `writeEnvValue` mirrors into the live env cache, so BUDGET.* (now
|
|
8798
|
+
// backed by getters) sees the new value on the very next tool call.
|
|
8789
8799
|
writeEnvValue(key, normalized);
|
|
8790
|
-
process.env[key] = normalized;
|
|
8791
8800
|
return { ok: true, value: normalized };
|
|
8792
8801
|
}
|
|
8793
8802
|
function readRecentDashboardChatFailures(limit = 5) {
|
|
@@ -9046,7 +9055,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
9046
9055
|
}
|
|
9047
9056
|
res.json({
|
|
9048
9057
|
ok: true,
|
|
9049
|
-
message: `${key} set to ${formatDashboardBudgetValue(result.value)}.
|
|
9058
|
+
message: `${key} set to ${formatDashboardBudgetValue(result.value)}. Applied to running workers immediately.`,
|
|
9050
9059
|
});
|
|
9051
9060
|
}
|
|
9052
9061
|
catch (err) {
|
|
@@ -9060,11 +9069,11 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
9060
9069
|
let message;
|
|
9061
9070
|
if (preset === 'defaults' || preset === 'standard') {
|
|
9062
9071
|
writes = DASHBOARD_BUDGET_ROWS.map(row => ({ key: row.key, value: row.value }));
|
|
9063
|
-
message = 'Restored the standard spend caps.
|
|
9072
|
+
message = 'Restored the standard spend caps. Applied to running workers immediately.';
|
|
9064
9073
|
}
|
|
9065
9074
|
else if (preset === 'uncapped' || preset === 'off' || preset === 'none') {
|
|
9066
9075
|
writes = DASHBOARD_BUDGET_ROWS.map(row => ({ key: row.key, value: '0' }));
|
|
9067
|
-
message = 'Removed spend caps by setting all budget values to 0.
|
|
9076
|
+
message = 'Removed spend caps by setting all budget values to 0. Applied to running workers immediately. (1M context mode is separate — use Force 200K or Safe Recovery for 1M errors.)';
|
|
9068
9077
|
}
|
|
9069
9078
|
else {
|
|
9070
9079
|
res.status(400).json({ error: 'preset must be defaults or uncapped' });
|
package/dist/config.d.ts
CHANGED
|
@@ -40,6 +40,17 @@ export declare function usesOneMillionContext(model: string | null | undefined,
|
|
|
40
40
|
export declare function getEnv(key: string, fallback?: string): string;
|
|
41
41
|
/** Merged view of process.env overlaid with .env. Use for classifyIntegrations / summarizeIntegrationStatus. */
|
|
42
42
|
export declare function envSnapshot(): Record<string, string | undefined>;
|
|
43
|
+
/**
|
|
44
|
+
* Hot-update a config value at runtime. Call this from any code path that
|
|
45
|
+
* persists a config change (e.g. dashboard `/api/budgets/set`) so the
|
|
46
|
+
* in-module `env` cache stays in sync with what's on disk + in process.env.
|
|
47
|
+
*
|
|
48
|
+
* Without this, `getEnv` keeps returning the value that was read from .env
|
|
49
|
+
* at module init and frozen objects like BUDGET stay stale until the
|
|
50
|
+
* daemon restarts — that's how a "Budgets at zero in the dashboard" UI can
|
|
51
|
+
* coexist with a `Hit the $1.00 cron budget cap` error on the same minute.
|
|
52
|
+
*/
|
|
53
|
+
export declare function setEnvOverride(key: string, value: string): void;
|
|
43
54
|
/** Test-only: clear the keychain ref cache so re-resolution can be tested. */
|
|
44
55
|
export declare function _resetKeychainRefCache(): void;
|
|
45
56
|
/**
|
|
@@ -83,14 +94,14 @@ export declare const ASSISTANT_EXPERIENCE: {
|
|
|
83
94
|
export declare const shellEscape: typeof _shellEscape;
|
|
84
95
|
export declare const MODELS: Models;
|
|
85
96
|
export declare const BUDGET: {
|
|
86
|
-
heartbeat: number;
|
|
87
|
-
cronT1: number;
|
|
88
|
-
cronT2: number;
|
|
89
|
-
chat: number;
|
|
90
|
-
unleashedPhase: undefined;
|
|
91
|
-
memoryExtraction: undefined;
|
|
92
|
-
summarization: undefined;
|
|
93
|
-
reflection: undefined;
|
|
97
|
+
readonly heartbeat: number;
|
|
98
|
+
readonly cronT1: number;
|
|
99
|
+
readonly cronT2: number;
|
|
100
|
+
readonly chat: number;
|
|
101
|
+
readonly unleashedPhase: number | undefined;
|
|
102
|
+
readonly memoryExtraction: number | undefined;
|
|
103
|
+
readonly summarization: number | undefined;
|
|
104
|
+
readonly reflection: number | undefined;
|
|
94
105
|
};
|
|
95
106
|
export declare const MEMORY_JANITOR: {
|
|
96
107
|
consolidatedExpireDays: number;
|
package/dist/config.js
CHANGED
|
@@ -285,6 +285,20 @@ export function getEnv(key, fallback = '') {
|
|
|
285
285
|
export function envSnapshot() {
|
|
286
286
|
return { ...process.env, ...env };
|
|
287
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Hot-update a config value at runtime. Call this from any code path that
|
|
290
|
+
* persists a config change (e.g. dashboard `/api/budgets/set`) so the
|
|
291
|
+
* in-module `env` cache stays in sync with what's on disk + in process.env.
|
|
292
|
+
*
|
|
293
|
+
* Without this, `getEnv` keeps returning the value that was read from .env
|
|
294
|
+
* at module init and frozen objects like BUDGET stay stale until the
|
|
295
|
+
* daemon restarts — that's how a "Budgets at zero in the dashboard" UI can
|
|
296
|
+
* coexist with a `Hit the $1.00 cron budget cap` error on the same minute.
|
|
297
|
+
*/
|
|
298
|
+
export function setEnvOverride(key, value) {
|
|
299
|
+
env[key] = value;
|
|
300
|
+
process.env[key] = value;
|
|
301
|
+
}
|
|
288
302
|
/** Test-only: clear the keychain ref cache so re-resolution can be tested. */
|
|
289
303
|
export function _resetKeychainRefCache() {
|
|
290
304
|
resolvedKeychainRefs.clear();
|
|
@@ -379,11 +393,14 @@ export const MODELS = {
|
|
|
379
393
|
// User-tunable via `clementine config set BUDGET_<NAME>_USD <value>`
|
|
380
394
|
// (writes to ~/.clementine/.env, survives npm update -g) or via
|
|
381
395
|
// `budgets.*` keys in clementine.json.
|
|
396
|
+
// Live getters — each property re-reads .env + process.env on access so a
|
|
397
|
+
// dashboard write (via setEnvOverride) takes effect on the *next* tool call
|
|
398
|
+
// without needing a daemon restart. Defaults match the previous fixed values.
|
|
382
399
|
export const BUDGET = {
|
|
383
|
-
heartbeat
|
|
384
|
-
cronT1
|
|
385
|
-
cronT2
|
|
386
|
-
chat
|
|
400
|
+
get heartbeat() { return getEnvOrJsonNumber('BUDGET_HEARTBEAT_USD', json.budgets?.heartbeat, 0.25); },
|
|
401
|
+
get cronT1() { return getEnvOrJsonNumber('BUDGET_CRON_T1_USD', json.budgets?.cronT1, 0.75); },
|
|
402
|
+
get cronT2() { return getEnvOrJsonNumber('BUDGET_CRON_T2_USD', json.budgets?.cronT2, 1.50); },
|
|
403
|
+
get chat() { return getEnvOrJsonNumber('BUDGET_CHAT_USD', json.budgets?.chat, 5.00); },
|
|
387
404
|
unleashedPhase: undefined,
|
|
388
405
|
memoryExtraction: undefined,
|
|
389
406
|
summarization: undefined,
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export declare function buildContextOverflowRetryPrompt(opts: {
|
|
|
18
18
|
turnContextPrefix?: string;
|
|
19
19
|
project?: ProjectMeta | null;
|
|
20
20
|
}): string;
|
|
21
|
+
export declare function runAgentResultIndicatesContextOverflow(result: {
|
|
22
|
+
subtype?: string;
|
|
23
|
+
terminalReason?: string;
|
|
24
|
+
text?: string;
|
|
25
|
+
}): boolean;
|
|
21
26
|
export type ChatErrorKind = 'rate_limit' | 'one_million_context' | 'context_overflow' | 'auth' | 'billing' | 'transient' | 'unknown';
|
|
22
27
|
export declare function classifyChatError(err: unknown): ChatErrorKind;
|
|
23
28
|
/** Detect auth-like errors in response text that the SDK returned as "successful" results. */
|
|
@@ -64,13 +69,14 @@ export declare class Gateway {
|
|
|
64
69
|
private extractBackgroundTaskId;
|
|
65
70
|
private makeBackgroundOfferId;
|
|
66
71
|
private backgroundAgentForSession;
|
|
72
|
+
private buildBackgroundTaskPrompt;
|
|
67
73
|
private pruneExpiredBackgroundOffers;
|
|
68
74
|
private latestBackgroundOfferForSession;
|
|
69
75
|
private getBackgroundOfferForSession;
|
|
70
76
|
private createBackgroundOffer;
|
|
71
77
|
private queueBackgroundOffer;
|
|
72
78
|
private formatBackgroundQueuedResponse;
|
|
73
|
-
private
|
|
79
|
+
private queueBackgroundTaskAfterContextOverflow;
|
|
74
80
|
acceptBackgroundOffer(sessionKey: string, id: string): {
|
|
75
81
|
ok: boolean;
|
|
76
82
|
response: string;
|
package/dist/gateway/router.js
CHANGED
|
@@ -96,6 +96,17 @@ export function buildContextOverflowRetryPrompt(opts) {
|
|
|
96
96
|
parts.push(opts.chatPrompt);
|
|
97
97
|
return parts.filter(Boolean).join('\n\n');
|
|
98
98
|
}
|
|
99
|
+
export function runAgentResultIndicatesContextOverflow(result) {
|
|
100
|
+
const terminalReason = (result.terminalReason ?? '').trim();
|
|
101
|
+
if (terminalReason && classifyChatError(terminalReason) === 'context_overflow')
|
|
102
|
+
return true;
|
|
103
|
+
const subtype = (result.subtype ?? '').trim();
|
|
104
|
+
if (subtype && subtype !== 'success' && classifyChatError(subtype) === 'context_overflow')
|
|
105
|
+
return true;
|
|
106
|
+
const text = (result.text ?? '').trim();
|
|
107
|
+
return /^Autocompact is thrashing:\s*the context refilled to the limit/i.test(text)
|
|
108
|
+
|| /^rapid_refill_breaker\b/i.test(text);
|
|
109
|
+
}
|
|
99
110
|
export function classifyChatError(err) {
|
|
100
111
|
const msg = String(err);
|
|
101
112
|
if (isCreditBalanceError(msg))
|
|
@@ -299,6 +310,18 @@ export class Gateway {
|
|
|
299
310
|
backgroundAgentForSession(sessionKey) {
|
|
300
311
|
return this._agentSlugFromSessionKey(sessionKey) ?? this.getSessionProfile(sessionKey) ?? 'clementine';
|
|
301
312
|
}
|
|
313
|
+
buildBackgroundTaskPrompt(sessionKey, prompt) {
|
|
314
|
+
const sess = this.sessions.get(sessionKey);
|
|
315
|
+
const parts = [
|
|
316
|
+
'[Background task from chat: run this in a fresh task execution. Do not rely on the live chat transcript being resumed; use the self-contained request below.]',
|
|
317
|
+
];
|
|
318
|
+
if (sess?.project?.path) {
|
|
319
|
+
const description = sess.project.description ? ` (${sess.project.description})` : '';
|
|
320
|
+
parts.push(`[Active project: ${sess.project.path}${description}]`);
|
|
321
|
+
}
|
|
322
|
+
parts.push(prompt.trim());
|
|
323
|
+
return parts.filter(Boolean).join('\n\n');
|
|
324
|
+
}
|
|
302
325
|
pruneExpiredBackgroundOffers() {
|
|
303
326
|
const now = Date.now();
|
|
304
327
|
for (const [id, offer] of this.pendingBackgroundOffers) {
|
|
@@ -327,6 +350,7 @@ export class Gateway {
|
|
|
327
350
|
sessionKey,
|
|
328
351
|
fromAgent: this.backgroundAgentForSession(sessionKey),
|
|
329
352
|
prompt,
|
|
353
|
+
taskPrompt: this.buildBackgroundTaskPrompt(sessionKey, prompt),
|
|
330
354
|
recommendation,
|
|
331
355
|
createdAt: Date.now(),
|
|
332
356
|
expiresAt: Date.now() + 30 * 60_000,
|
|
@@ -337,7 +361,7 @@ export class Gateway {
|
|
|
337
361
|
queueBackgroundOffer(offer) {
|
|
338
362
|
const task = createBackgroundTask({
|
|
339
363
|
fromAgent: offer.fromAgent,
|
|
340
|
-
prompt: offer.
|
|
364
|
+
prompt: offer.taskPrompt,
|
|
341
365
|
maxMinutes: offer.recommendation.suggestedMaxMinutes,
|
|
342
366
|
sessionKey: offer.sessionKey,
|
|
343
367
|
});
|
|
@@ -355,25 +379,37 @@ export class Gateway {
|
|
|
355
379
|
return [
|
|
356
380
|
`Queued background task **${task.id}**.`,
|
|
357
381
|
'',
|
|
358
|
-
`It will run as **${task.fromAgent}** with a ${task.maxMinutes} minute cap.`,
|
|
382
|
+
`It will run as **${task.fromAgent}** in a fresh task session with a ${task.maxMinutes} minute cap.`,
|
|
359
383
|
`Use \`status ${task.id}\` or check the dashboard Background Tasks panel for progress.`,
|
|
360
384
|
].join('\n');
|
|
361
385
|
}
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
386
|
+
queueBackgroundTaskAfterContextOverflow(sessionKey, prompt) {
|
|
387
|
+
const recommendation = detectComplexTaskForBackground(prompt);
|
|
388
|
+
const task = createBackgroundTask({
|
|
389
|
+
fromAgent: this.backgroundAgentForSession(sessionKey),
|
|
390
|
+
prompt,
|
|
391
|
+
maxMinutes: recommendation?.suggestedMaxMinutes ?? 60,
|
|
392
|
+
sessionKey,
|
|
393
|
+
});
|
|
394
|
+
logger.warn({
|
|
395
|
+
taskId: task.id,
|
|
396
|
+
sessionKey,
|
|
397
|
+
fromAgent: task.fromAgent,
|
|
398
|
+
maxMinutes: task.maxMinutes,
|
|
399
|
+
}, 'Queued background task after repeated chat context overflow');
|
|
400
|
+
return {
|
|
401
|
+
task,
|
|
402
|
+
response: [
|
|
403
|
+
`The live chat context hit the limit, so I moved this into background task **${task.id}** and kept your request attached.`,
|
|
404
|
+
'',
|
|
405
|
+
`It will run as **${task.fromAgent}** in a fresh task session with a ${task.maxMinutes} minute cap.`,
|
|
406
|
+
`Use \`status ${task.id}\` or check the dashboard Background Tasks panel for progress.`,
|
|
407
|
+
].join('\n'),
|
|
408
|
+
};
|
|
376
409
|
}
|
|
410
|
+
// Offer-message formatter was removed in the Saturday-feel restoration —
|
|
411
|
+
// the chat path no longer asks "want me to run this in the background?".
|
|
412
|
+
// Auto-queue on explicit user intent is silent; everything else just runs.
|
|
377
413
|
acceptBackgroundOffer(sessionKey, id) {
|
|
378
414
|
const offer = this.getBackgroundOfferForSession(sessionKey, id);
|
|
379
415
|
if (!offer) {
|
|
@@ -1918,45 +1954,38 @@ export class Gateway {
|
|
|
1918
1954
|
|| text.startsWith('[Approval:')
|
|
1919
1955
|
|| text.startsWith('[Reaction:')
|
|
1920
1956
|
|| text.startsWith('[System:');
|
|
1957
|
+
// ── Explicit background-intent shortcut ────────────────────────
|
|
1958
|
+
// Chat normally runs in-place — the SDK auto-compacts and the model
|
|
1959
|
+
// can spawn `planner` / `researcher` subagents for context-heavy
|
|
1960
|
+
// sub-steps, just like Claude Code. We only auto-queue a durable
|
|
1961
|
+
// background task when the user *explicitly* says "in the
|
|
1962
|
+
// background", "overnight", "keep working", "don't stop", etc. The
|
|
1963
|
+
// post-overflow rescue path below still catches the rare case
|
|
1964
|
+
// where chat actually drowns despite all that.
|
|
1921
1965
|
if (!skipBackgroundOffer && !isBuilderSession && !isInternalMsg && this.isTrustedPersonalSession(sessionKey)) {
|
|
1922
1966
|
const recommendation = detectComplexTaskForBackground(text);
|
|
1923
1967
|
if (recommendation) {
|
|
1924
1968
|
const offer = this.createBackgroundOffer(sessionKey, text, recommendation);
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
const queued = this.formatBackgroundQueuedResponse(task);
|
|
1928
|
-
if (ledgerRunMetadata) {
|
|
1929
|
-
ledgerRunMetadata.executionMode = 'background_queued';
|
|
1930
|
-
ledgerRunMetadata.backgroundTaskId = task.id;
|
|
1931
|
-
}
|
|
1932
|
-
if (onText) {
|
|
1933
|
-
try {
|
|
1934
|
-
await onText(queued);
|
|
1935
|
-
}
|
|
1936
|
-
catch { /* channel streaming is best-effort */ }
|
|
1937
|
-
}
|
|
1938
|
-
this.mirrorChatExchange(sessionKey, originalText, queued, { model: 'chat-control' });
|
|
1939
|
-
return queued;
|
|
1940
|
-
}
|
|
1941
|
-
const offerText = this.formatBackgroundOfferResponse(offer);
|
|
1969
|
+
const task = this.queueBackgroundOffer(offer);
|
|
1970
|
+
const queued = this.formatBackgroundQueuedResponse(task);
|
|
1942
1971
|
if (ledgerRunMetadata) {
|
|
1943
|
-
ledgerRunMetadata.executionMode = '
|
|
1972
|
+
ledgerRunMetadata.executionMode = 'background_queued';
|
|
1973
|
+
ledgerRunMetadata.backgroundTaskId = task.id;
|
|
1944
1974
|
}
|
|
1945
1975
|
logger.info({
|
|
1946
1976
|
sessionKey,
|
|
1947
|
-
|
|
1948
|
-
score: recommendation.score,
|
|
1977
|
+
taskId: task.id,
|
|
1949
1978
|
reasons: recommendation.reasons,
|
|
1950
1979
|
maxMinutes: recommendation.suggestedMaxMinutes,
|
|
1951
|
-
}, '
|
|
1980
|
+
}, 'Auto-queued background task on explicit user intent');
|
|
1952
1981
|
if (onText) {
|
|
1953
1982
|
try {
|
|
1954
|
-
await onText(
|
|
1983
|
+
await onText(queued);
|
|
1955
1984
|
}
|
|
1956
1985
|
catch { /* channel streaming is best-effort */ }
|
|
1957
1986
|
}
|
|
1958
|
-
this.mirrorChatExchange(sessionKey, originalText,
|
|
1959
|
-
return
|
|
1987
|
+
this.mirrorChatExchange(sessionKey, originalText, queued, { model: 'chat-control' });
|
|
1988
|
+
return queued;
|
|
1960
1989
|
}
|
|
1961
1990
|
}
|
|
1962
1991
|
if (!isInternalMsg && !sess?.profile && !text.startsWith('!') && !isStructuredWorkflowMsg && onProgress) {
|
|
@@ -2080,6 +2109,7 @@ export class Gateway {
|
|
|
2080
2109
|
// Interrupt flag was set but no useful partial text — just clear it.
|
|
2081
2110
|
delete sessState.pendingInterrupt;
|
|
2082
2111
|
}
|
|
2112
|
+
let contextOverflowRecoveryPrompt = '';
|
|
2083
2113
|
try {
|
|
2084
2114
|
// ── Canonical SDK chat path (Phase 5) ────────────────────────
|
|
2085
2115
|
// runAgent() owns chat. No legacy fallback — errors propagate
|
|
@@ -2201,22 +2231,18 @@ export class Gateway {
|
|
|
2201
2231
|
},
|
|
2202
2232
|
abortSignal: chatAc.signal,
|
|
2203
2233
|
});
|
|
2204
|
-
let
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
}
|
|
2211
|
-
catch (err) {
|
|
2212
|
-
if (chatAc.signal.aborted || classifyChatError(err) !== 'context_overflow') {
|
|
2213
|
-
throw err;
|
|
2214
|
-
}
|
|
2234
|
+
let didContextOverflowRetry = false;
|
|
2235
|
+
const contextOverflowAfterRetryError = () => new Error('rapid_refill_breaker after context overflow retry');
|
|
2236
|
+
const retryAfterContextOverflow = async () => {
|
|
2237
|
+
if (didContextOverflowRetry)
|
|
2238
|
+
throw contextOverflowAfterRetryError();
|
|
2239
|
+
didContextOverflowRetry = true;
|
|
2215
2240
|
const retryPrompt = buildContextOverflowRetryPrompt({
|
|
2216
2241
|
chatPrompt,
|
|
2217
2242
|
turnContextPrefix,
|
|
2218
2243
|
project: sess?.project ?? null,
|
|
2219
2244
|
});
|
|
2245
|
+
contextOverflowRecoveryPrompt = retryPrompt;
|
|
2220
2246
|
logger.info({
|
|
2221
2247
|
sessionKey: effectiveSessionKey,
|
|
2222
2248
|
hadResume: !!priorSdkSessionId,
|
|
@@ -2229,9 +2255,49 @@ export class Gateway {
|
|
|
2229
2255
|
await onProgress('refreshing conversation context...').catch(() => { });
|
|
2230
2256
|
}
|
|
2231
2257
|
this.assistant.clearSession(effectiveSessionKey);
|
|
2232
|
-
|
|
2258
|
+
return runAgent(retryPrompt, buildRunAgentChatOptions({
|
|
2233
2259
|
...(retrySystemAppend ? { systemPromptAppend: retrySystemAppend } : {}),
|
|
2234
2260
|
}));
|
|
2261
|
+
};
|
|
2262
|
+
let runAgentResult;
|
|
2263
|
+
try {
|
|
2264
|
+
runAgentResult = await runAgent(finalPrompt, buildRunAgentChatOptions({
|
|
2265
|
+
...(priorSdkSessionId ? { resumeSessionId: priorSdkSessionId } : {}),
|
|
2266
|
+
...(chatSystemAppend ? { systemPromptAppend: chatSystemAppend } : {}),
|
|
2267
|
+
}));
|
|
2268
|
+
}
|
|
2269
|
+
catch (err) {
|
|
2270
|
+
if (chatAc.signal.aborted || classifyChatError(err) !== 'context_overflow') {
|
|
2271
|
+
throw err;
|
|
2272
|
+
}
|
|
2273
|
+
runAgentResult = await retryAfterContextOverflow();
|
|
2274
|
+
}
|
|
2275
|
+
if (!chatAc.signal.aborted && runAgentResultIndicatesContextOverflow(runAgentResult)) {
|
|
2276
|
+
if (didContextOverflowRetry) {
|
|
2277
|
+
logger.info({
|
|
2278
|
+
sessionKey: effectiveSessionKey,
|
|
2279
|
+
subtype: runAgentResult.subtype,
|
|
2280
|
+
terminalReason: runAgentResult.terminalReason,
|
|
2281
|
+
textPreview: runAgentResult.text?.slice(0, 240),
|
|
2282
|
+
}, 'Context overflow result after retry — queueing background task');
|
|
2283
|
+
throw contextOverflowAfterRetryError();
|
|
2284
|
+
}
|
|
2285
|
+
logger.info({
|
|
2286
|
+
sessionKey: effectiveSessionKey,
|
|
2287
|
+
subtype: runAgentResult.subtype,
|
|
2288
|
+
terminalReason: runAgentResult.terminalReason,
|
|
2289
|
+
textPreview: runAgentResult.text?.slice(0, 240),
|
|
2290
|
+
}, 'Context overflow result — retrying current message in fresh SDK session');
|
|
2291
|
+
runAgentResult = await retryAfterContextOverflow();
|
|
2292
|
+
if (runAgentResultIndicatesContextOverflow(runAgentResult)) {
|
|
2293
|
+
logger.info({
|
|
2294
|
+
sessionKey: effectiveSessionKey,
|
|
2295
|
+
subtype: runAgentResult.subtype,
|
|
2296
|
+
terminalReason: runAgentResult.terminalReason,
|
|
2297
|
+
textPreview: runAgentResult.text?.slice(0, 240),
|
|
2298
|
+
}, 'Context overflow result after retry — queueing background task');
|
|
2299
|
+
throw contextOverflowAfterRetryError();
|
|
2300
|
+
}
|
|
2235
2301
|
}
|
|
2236
2302
|
if (ledgerRunMetadata) {
|
|
2237
2303
|
ledgerRunMetadata.runId = runAgentResult.runId;
|
|
@@ -2309,9 +2375,18 @@ export class Gateway {
|
|
|
2309
2375
|
this.clearSession(effectiveSessionKey);
|
|
2310
2376
|
return oneMillionContextRecoveryMessage();
|
|
2311
2377
|
case 'context_overflow':
|
|
2312
|
-
logger.info({ sessionKey }, 'Context overflow —
|
|
2378
|
+
logger.info({ sessionKey }, 'Context overflow after retry — queueing background task');
|
|
2313
2379
|
this.assistant.clearSession(effectiveSessionKey);
|
|
2314
|
-
|
|
2380
|
+
{
|
|
2381
|
+
const promptForBackground = contextOverflowRecoveryPrompt || chatPrompt;
|
|
2382
|
+
const { response, task } = this.queueBackgroundTaskAfterContextOverflow(sessionKey, promptForBackground);
|
|
2383
|
+
if (ledgerRunMetadata) {
|
|
2384
|
+
ledgerRunMetadata.executionMode = 'background_queued';
|
|
2385
|
+
ledgerRunMetadata.backgroundTaskId = task.id;
|
|
2386
|
+
}
|
|
2387
|
+
this.mirrorChatExchange(sessionKey, originalText, response, { model: 'chat-control' });
|
|
2388
|
+
return response;
|
|
2389
|
+
}
|
|
2315
2390
|
case 'auth':
|
|
2316
2391
|
this.recordAuthFailure();
|
|
2317
2392
|
return "I'm temporarily offline due to an authentication issue. The owner needs to re-authenticate — I'll recover automatically once it's resolved.";
|
|
@@ -32,6 +32,12 @@ export declare function isComposioEnabled(): boolean;
|
|
|
32
32
|
* the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
|
|
33
33
|
*/
|
|
34
34
|
export declare function resetComposioClient(): void;
|
|
35
|
+
/**
|
|
36
|
+
* Drop the per-process connection-list cache so the next call to
|
|
37
|
+
* `listConnectedToolkits()` hits Composio fresh. Used after authorize /
|
|
38
|
+
* disconnect / rename so the dashboard and agent see the change immediately.
|
|
39
|
+
*/
|
|
40
|
+
export declare function clearConnectedToolkitsCache(): void;
|
|
35
41
|
export declare function getPreferredUserId(): Promise<string>;
|
|
36
42
|
export declare function clementineUserId(): string;
|
|
37
43
|
export declare function displayNameFor(slug: string): string;
|
|
@@ -83,6 +83,30 @@ export function resetComposioClient() {
|
|
|
83
83
|
identityCache.clear();
|
|
84
84
|
catalogCache = null;
|
|
85
85
|
detectedPreferredUserId = null;
|
|
86
|
+
connectionsCache = null;
|
|
87
|
+
void busComposioMcpCache();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Drop the per-process connection-list cache so the next call to
|
|
91
|
+
* `listConnectedToolkits()` hits Composio fresh. Used after authorize /
|
|
92
|
+
* disconnect / rename so the dashboard and agent see the change immediately.
|
|
93
|
+
*/
|
|
94
|
+
export function clearConnectedToolkitsCache() {
|
|
95
|
+
connectionsCache = null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Fire-and-forget MCP-server cache bust. Imported lazily to avoid the
|
|
99
|
+
* client → mcp-bridge → client cycle that an `import { ... }` at the top
|
|
100
|
+
* would create.
|
|
101
|
+
*/
|
|
102
|
+
async function busComposioMcpCache() {
|
|
103
|
+
try {
|
|
104
|
+
const mod = await import('./mcp-bridge.js');
|
|
105
|
+
mod.clearComposioMcpCache?.();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* mcp-bridge optional at boot; safe to ignore */
|
|
109
|
+
}
|
|
86
110
|
}
|
|
87
111
|
// Public: same logic as the internal detector, exposed for the MCP bridge so
|
|
88
112
|
// agent sessions land on the right user_id.
|
|
@@ -310,10 +334,22 @@ async function getIdentityFor(composio, id, slug, seed) {
|
|
|
310
334
|
identityCache.set(id, { at: Date.now(), identity });
|
|
311
335
|
return identity;
|
|
312
336
|
}
|
|
337
|
+
// Short-lived per-process cache + stale-while-revalidate. Composio API hiccups
|
|
338
|
+
// between turns used to make tools "vanish" from the chat; with this, a single
|
|
339
|
+
// failed list call falls back to the prior good snapshot. TTL is short enough
|
|
340
|
+
// (60s) that legit reconnects / disconnects show up quickly, and the dashboard
|
|
341
|
+
// auth/disconnect handlers explicitly bust the cache via
|
|
342
|
+
// `clearConnectedToolkitsCache()` for instant reflection.
|
|
343
|
+
let connectionsCache = null;
|
|
344
|
+
const CONNECTIONS_TTL_MS = 60_000;
|
|
313
345
|
export async function listConnectedToolkits() {
|
|
314
346
|
const composio = getComposio();
|
|
315
347
|
if (!composio)
|
|
316
348
|
return [];
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
if (connectionsCache && now - connectionsCache.at < CONNECTIONS_TTL_MS) {
|
|
351
|
+
return connectionsCache.data;
|
|
352
|
+
}
|
|
317
353
|
try {
|
|
318
354
|
// No userIds filter: a Composio API key is account-scoped, and a personal
|
|
319
355
|
// agent should see every connection on the account regardless of which
|
|
@@ -337,10 +373,15 @@ export async function listConnectedToolkits() {
|
|
|
337
373
|
createdAt: it.createdAt,
|
|
338
374
|
};
|
|
339
375
|
}));
|
|
376
|
+
connectionsCache = { at: now, data: enriched };
|
|
340
377
|
return enriched;
|
|
341
378
|
}
|
|
342
379
|
catch (err) {
|
|
343
|
-
|
|
380
|
+
if (connectionsCache) {
|
|
381
|
+
logger.warn({ err, staleAgeMs: now - connectionsCache.at, items: connectionsCache.data.length }, 'listConnectedToolkits failed — returning stale cache');
|
|
382
|
+
return connectionsCache.data;
|
|
383
|
+
}
|
|
384
|
+
logger.error({ err }, 'listConnectedToolkits failed (no cache to fall back to)');
|
|
344
385
|
return [];
|
|
345
386
|
}
|
|
346
387
|
}
|
|
@@ -481,6 +522,8 @@ _opts) {
|
|
|
481
522
|
// others created in parallel via Composio's web UI) get picked up
|
|
482
523
|
// immediately, even within the 60s TTL window.
|
|
483
524
|
detectedPreferredUserId = null;
|
|
525
|
+
connectionsCache = null;
|
|
526
|
+
void busComposioMcpCache();
|
|
484
527
|
return { redirectUrl: conn.redirectUrl ?? null, connectionId: conn.id };
|
|
485
528
|
}
|
|
486
529
|
catch (err) {
|
|
@@ -509,6 +552,8 @@ export async function disconnectToolkit(connectionId) {
|
|
|
509
552
|
throw new Error('COMPOSIO_API_KEY not set');
|
|
510
553
|
await composio.connectedAccounts.delete(connectionId);
|
|
511
554
|
identityCache.delete(connectionId);
|
|
555
|
+
connectionsCache = null;
|
|
556
|
+
void busComposioMcpCache();
|
|
512
557
|
}
|
|
513
558
|
export async function renameConnection(connectionId, alias) {
|
|
514
559
|
const composio = getComposio();
|
|
@@ -520,5 +565,6 @@ export async function renameConnection(connectionId, alias) {
|
|
|
520
565
|
// hatch and the alternative (bypassing the wrapper entirely) loses retry
|
|
521
566
|
// and auth handling.
|
|
522
567
|
await composio.client.connectedAccounts.patch(connectionId, { alias });
|
|
568
|
+
connectionsCache = null;
|
|
523
569
|
}
|
|
524
570
|
//# sourceMappingURL=client.js.map
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* always works — Composio is purely additive.
|
|
17
17
|
*/
|
|
18
18
|
import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
+
export declare function clearComposioMcpCache(slug?: string): void;
|
|
19
20
|
/**
|
|
20
21
|
* Build SDK MCP server configs for the given toolkit slugs (or all active
|
|
21
22
|
* connected toolkits when omitted). Each toolkit becomes one MCP server.
|
|
@@ -19,6 +19,15 @@ import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
|
|
|
19
19
|
import pino from 'pino';
|
|
20
20
|
import { getComposio, getPreferredUserId, listConnectedToolkits, } from './client.js';
|
|
21
21
|
const logger = pino({ name: 'clementine.composio.mcp' });
|
|
22
|
+
const serverCache = new Map();
|
|
23
|
+
const SERVER_CACHE_TTL_MS = 5 * 60_000;
|
|
24
|
+
export function clearComposioMcpCache(slug) {
|
|
25
|
+
if (slug) {
|
|
26
|
+
serverCache.delete(slug);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
serverCache.clear();
|
|
30
|
+
}
|
|
22
31
|
/**
|
|
23
32
|
* Build SDK MCP server configs for the given toolkit slugs (or all active
|
|
24
33
|
* connected toolkits when omitted). Each toolkit becomes one MCP server.
|
|
@@ -70,6 +79,11 @@ export async function listComposioToolkitTools(slugs) {
|
|
|
70
79
|
return out;
|
|
71
80
|
}
|
|
72
81
|
async function buildOne(composio, slug, _connected) {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const cached = serverCache.get(slug);
|
|
84
|
+
if (cached && now - cached.at < SERVER_CACHE_TTL_MS) {
|
|
85
|
+
return cached.server;
|
|
86
|
+
}
|
|
73
87
|
// composio.tools.get() returns the FLAT toolkit tools (OUTLOOK_LIST_MESSAGES,
|
|
74
88
|
// GMAIL_SEND_EMAIL, …) — exactly the namespacing the agent expects as
|
|
75
89
|
// mcp__outlook__OUTLOOK_LIST_MESSAGES. The alternative, composio.create()
|
|
@@ -83,11 +97,13 @@ async function buildOne(composio, slug, _connected) {
|
|
|
83
97
|
// alphabetically come after OUTLOOK_LIST_CALENDAR_GROUP_*. GitHub has
|
|
84
98
|
// 800+. Set 1000 — comfortable headroom for any single toolkit.
|
|
85
99
|
const tools = await fetchToolkitTools(composio, slug);
|
|
86
|
-
|
|
100
|
+
const server = createSdkMcpServer({
|
|
87
101
|
name: slug,
|
|
88
102
|
version: '0.1.0',
|
|
89
103
|
tools: tools,
|
|
90
104
|
});
|
|
105
|
+
serverCache.set(slug, { at: now, server });
|
|
106
|
+
return server;
|
|
91
107
|
}
|
|
92
108
|
async function fetchToolkitTools(composio, slug) {
|
|
93
109
|
const userId = await getPreferredUserId();
|