clementine-agent 1.18.59 → 1.18.60

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.
@@ -17,7 +17,7 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import pino from 'pino';
20
- import { BASE_DIR, VAULT_DIR, CRON_PROGRESS_DIR, } from '../config.js';
20
+ import { BASE_DIR, VAULT_DIR, CRON_PROGRESS_DIR, BUDGET, } from '../config.js';
21
21
  import { runAgent } from './run-agent.js';
22
22
  import { buildExtraMcpForRunAgent } from './run-agent-mcp.js';
23
23
  import { buildAutonomousMemoryContext } from './run-agent-context.js';
@@ -281,7 +281,12 @@ export async function runAgentCron(opts) {
281
281
  profile: opts.profile,
282
282
  });
283
283
  // ── Run via canonical runAgent ────────────────────────────────────
284
- const maxBudget = opts.maxBudgetUsd ?? (tier >= 2 ? 3.0 : 1.0);
284
+ // Per-tier cap from config (BUDGET.cronT1 / BUDGET.cronT2). Sourced
285
+ // from env / clementine.json / dashboard writes. 0 means uncapped —
286
+ // we pass undefined so runAgent omits the SDK option entirely.
287
+ // Caller can still override via opts.maxBudgetUsd.
288
+ const configuredCap = tier >= 2 ? BUDGET.cronT2 : BUDGET.cronT1;
289
+ const maxBudget = opts.maxBudgetUsd ?? (configuredCap > 0 ? configuredCap : undefined);
285
290
  const effort = tier >= 2 ? 'high' : 'medium';
286
291
  logger.info({
287
292
  job: opts.jobName,
@@ -302,7 +307,7 @@ export async function runAgentCron(opts) {
302
307
  memoryStore: opts.memoryStore,
303
308
  model: opts.model,
304
309
  effort,
305
- maxBudgetUsd: maxBudget,
310
+ ...(maxBudget !== undefined ? { maxBudgetUsd: maxBudget } : {}),
306
311
  maxTurns: opts.maxTurns,
307
312
  abortSignal: opts.abortSignal,
308
313
  extraMcpServers: mcp.servers,
@@ -14,7 +14,7 @@
14
14
  * through the canonical runAgent() instead of buildOptions+query.
15
15
  */
16
16
  import pino from 'pino';
17
- import { OWNER_NAME, MODELS, } from '../config.js';
17
+ import { OWNER_NAME, MODELS, BUDGET, } from '../config.js';
18
18
  const OWNER = OWNER_NAME || 'the user';
19
19
  function formatDate(d) {
20
20
  return d.toLocaleDateString('en-US', {
@@ -65,6 +65,10 @@ export async function runAgentHeartbeat(opts) {
65
65
  profile: opts.profile?.slug,
66
66
  promptChars: prompt.length,
67
67
  }, 'runAgentHeartbeat: dispatching to runAgent (no tools)');
68
+ // Heartbeat cap from config (BUDGET.heartbeat). Sourced from env /
69
+ // clementine.json / dashboard writes. 0 = uncapped — runAgent
70
+ // omits the SDK option in that case.
71
+ const heartbeatBudget = opts.maxBudgetUsd ?? (BUDGET.heartbeat > 0 ? BUDGET.heartbeat : undefined);
68
72
  const sessionKey = `heartbeat:${opts.profile?.slug ?? 'clementine'}`;
69
73
  const result = await runAgent(prompt, {
70
74
  sessionKey,
@@ -73,7 +77,7 @@ export async function runAgentHeartbeat(opts) {
73
77
  memoryStore: opts.memoryStore,
74
78
  model: opts.model ?? MODELS.haiku,
75
79
  effort: 'low',
76
- maxBudgetUsd: opts.maxBudgetUsd ?? 0.15,
80
+ ...(heartbeatBudget !== undefined ? { maxBudgetUsd: heartbeatBudget } : {}),
77
81
  maxTurns: 1,
78
82
  // No tools — heartbeats are decision-only. Empty list bypasses the
79
83
  // CORE_TOOLS_FOR_AGENT_PARENT default and stops the SDK from
@@ -62,8 +62,15 @@ function buildRunAgentEnv() {
62
62
  return env;
63
63
  }
64
64
  const logger = pino({ name: 'clementine.run-agent' });
65
+ // Last-resort fallbacks for callers that pass NO maxBudgetUsd. The
66
+ // production callers (`runAgent` from gateway/router, runAgentCron,
67
+ // runAgentHeartbeat) read `BUDGET.*` from src/config.ts — which is
68
+ // itself sourced from env / clementine.json / dashboard writes — and
69
+ // pass it explicitly. Chat is intentionally omitted: the chat path
70
+ // must always go through `BUDGET.chat` (0 = uncapped), never a silent
71
+ // hardcoded floor. If `source: 'chat'` ever lands here without an
72
+ // explicit budget, we treat it as uncapped.
65
73
  const DEFAULT_BUDGETS = {
66
- chat: 0.50,
67
74
  cron: 1.00,
68
75
  heartbeat: 0.25,
69
76
  'team-task': 1.00,
@@ -97,7 +104,13 @@ const CORE_TOOLS_FOR_AGENT_PARENT = [
97
104
  export async function runAgent(prompt, opts) {
98
105
  const source = opts.source ?? 'chat';
99
106
  const effort = opts.effort ?? DEFAULT_EFFORTS[source] ?? 'medium';
100
- const maxBudgetUsd = opts.maxBudgetUsd ?? DEFAULT_BUDGETS[source] ?? 0.50;
107
+ // 0 (or undefined) means "no cap" — matches the dashboard's
108
+ // "Remove spend caps" preset contract. We omit `maxBudgetUsd` from
109
+ // sdkOptions entirely in that case so the SDK runs uncapped.
110
+ const requestedBudget = opts.maxBudgetUsd ?? DEFAULT_BUDGETS[source];
111
+ const maxBudgetUsd = typeof requestedBudget === 'number' && requestedBudget > 0
112
+ ? requestedBudget
113
+ : undefined;
101
114
  const startedAt = Date.now();
102
115
  // Build the AgentDefinition map. Caller can override; otherwise we
103
116
  // use the standard system subagents + hired-agent profiles.
@@ -187,8 +200,8 @@ export async function runAgent(prompt, opts) {
187
200
  allowDangerouslySkipPermissions: true,
188
201
  cwd: BASE_DIR,
189
202
  env: subprocessEnv,
190
- maxBudgetUsd,
191
203
  effort,
204
+ ...(maxBudgetUsd !== undefined ? { maxBudgetUsd } : {}),
192
205
  ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
193
206
  ...(opts.model ? { model: opts.model } : {}),
194
207
  ...(opts.resumeSessionId ? { resume: opts.resumeSessionId } : {}),
@@ -201,7 +214,7 @@ export async function runAgent(prompt, opts) {
201
214
  profile: opts.profile?.slug,
202
215
  forceSubagent: opts.forceSubagent,
203
216
  effort,
204
- maxBudgetUsd,
217
+ maxBudgetUsd: maxBudgetUsd ?? 'uncapped',
205
218
  agentCount: Object.keys(agents).length,
206
219
  allowedToolCount: allowedTools.length,
207
220
  }, 'runAgent: starting query');
@@ -212,76 +225,93 @@ export async function runAgent(prompt, opts) {
212
225
  let subtype = 'unknown';
213
226
  let usage;
214
227
  const stream = query({ prompt: effectivePrompt, options: sdkOptions });
215
- for await (const message of stream) {
216
- if (message.type === 'system' && message.subtype === 'init') {
217
- sessionId = message.session_id ?? '';
218
- logger.debug({ sessionKey: opts.sessionKey, sdkSessionId: sessionId }, 'runAgent: SDK session initialized');
219
- continue;
220
- }
221
- if (message.type === 'assistant') {
222
- const am = message;
223
- const blocks = (am.message?.content ?? []);
224
- for (const block of blocks) {
225
- if (block.type === 'text' && typeof block.text === 'string') {
226
- finalText += block.text;
227
- if (opts.onText) {
228
- try {
229
- await opts.onText(block.text);
228
+ try {
229
+ for await (const message of stream) {
230
+ if (message.type === 'system' && message.subtype === 'init') {
231
+ sessionId = message.session_id ?? '';
232
+ logger.debug({ sessionKey: opts.sessionKey, sdkSessionId: sessionId }, 'runAgent: SDK session initialized');
233
+ continue;
234
+ }
235
+ if (message.type === 'assistant') {
236
+ const am = message;
237
+ const blocks = (am.message?.content ?? []);
238
+ for (const block of blocks) {
239
+ if (block.type === 'text' && typeof block.text === 'string') {
240
+ finalText += block.text;
241
+ if (opts.onText) {
242
+ try {
243
+ await opts.onText(block.text);
244
+ }
245
+ catch { /* streaming is best-effort */ }
230
246
  }
231
- catch { /* streaming is best-effort */ }
232
247
  }
233
- }
234
- else if (block.type === 'tool_use' && typeof block.name === 'string') {
235
- if (opts.onToolActivity) {
236
- try {
237
- await opts.onToolActivity({ tool: block.name, input: block.input ?? {} });
248
+ else if (block.type === 'tool_use' && typeof block.name === 'string') {
249
+ if (opts.onToolActivity) {
250
+ try {
251
+ await opts.onToolActivity({ tool: block.name, input: block.input ?? {} });
252
+ }
253
+ catch { /* best-effort */ }
238
254
  }
239
- catch { /* best-effort */ }
240
255
  }
241
256
  }
257
+ continue;
242
258
  }
243
- continue;
244
- }
245
- if (message.type === 'result') {
246
- const result = message;
247
- sessionId = sessionId || (result.session_id ?? '');
248
- subtype = result.subtype ?? 'unknown';
249
- numTurns = result.num_turns ?? numTurns;
250
- totalCostUsd = result.total_cost_usd ?? 0;
251
- const u = result.usage;
252
- if (u)
253
- usage = u;
254
- if (subtype === 'success') {
255
- // success carries `result` field with the final text.
256
- const r = result.result;
257
- if (r)
258
- finalText = r;
259
- }
260
- // Mirror cost to usage_log. Same shape as the existing
261
- // logQueryResult, but standalone so we don't depend on
262
- // PersonalAssistant's instance state.
263
- const modelUsage = result.modelUsage;
264
- if (opts.memoryStore && modelUsage) {
265
- try {
266
- opts.memoryStore.logUsage({
267
- sessionKey: `${source}:${opts.sessionKey}`,
268
- source: `runagent.${source}`,
269
- modelUsage,
270
- numTurns,
271
- durationMs: Date.now() - startedAt,
272
- agentSlug: opts.profile?.slug,
273
- totalCostUsd: totalCostUsd,
274
- });
259
+ if (message.type === 'result') {
260
+ const result = message;
261
+ sessionId = sessionId || (result.session_id ?? '');
262
+ subtype = result.subtype ?? 'unknown';
263
+ numTurns = result.num_turns ?? numTurns;
264
+ totalCostUsd = result.total_cost_usd ?? 0;
265
+ const u = result.usage;
266
+ if (u)
267
+ usage = u;
268
+ if (subtype === 'success') {
269
+ // success carries `result` field with the final text.
270
+ const r = result.result;
271
+ if (r)
272
+ finalText = r;
275
273
  }
276
- catch (err) {
277
- logger.debug({ err }, 'runAgent: usage logging failed (non-fatal)');
274
+ // Mirror cost to usage_log. Same shape as the existing
275
+ // logQueryResult, but standalone so we don't depend on
276
+ // PersonalAssistant's instance state.
277
+ const modelUsage = result.modelUsage;
278
+ if (opts.memoryStore && modelUsage) {
279
+ try {
280
+ opts.memoryStore.logUsage({
281
+ sessionKey: `${source}:${opts.sessionKey}`,
282
+ source: `runagent.${source}`,
283
+ modelUsage,
284
+ numTurns,
285
+ durationMs: Date.now() - startedAt,
286
+ agentSlug: opts.profile?.slug,
287
+ totalCostUsd: totalCostUsd,
288
+ });
289
+ }
290
+ catch (err) {
291
+ logger.debug({ err }, 'runAgent: usage logging failed (non-fatal)');
292
+ }
278
293
  }
294
+ continue;
279
295
  }
280
- continue;
296
+ // Other message types (UserMessage with tool_result, StreamEvent,
297
+ // SDKCompactBoundaryMessage) — observed but not acted on. The SDK
298
+ // handles compaction internally; we just let it run.
299
+ }
300
+ }
301
+ catch (err) {
302
+ // Translate the SDK's budget-exhaustion throw into a message that
303
+ // tells the user (a) what cap tripped and (b) how to raise it.
304
+ // The raw SDK string ("Claude Code returned an error result:
305
+ // Reached maximum budget ($0.5)") leaks through the channel layer
306
+ // as a generic "Something went wrong:" with no actionable hint.
307
+ const msg = String(err?.message ?? err);
308
+ if (/Reached maximum budget|error_max_budget_usd/i.test(msg)) {
309
+ const cap = maxBudgetUsd?.toFixed(2) ?? '?';
310
+ const envKey = `BUDGET_${source.toUpperCase().replace(/-/g, '_')}_USD`;
311
+ throw new Error(`Hit the $${cap} ${source} budget cap before finishing. ` +
312
+ `Raise it in the dashboard (Budgets & Costs) or set ${envKey}=0 to remove caps.`);
281
313
  }
282
- // Other message types (UserMessage with tool_result, StreamEvent,
283
- // SDKCompactBoundaryMessage) — observed but not acted on. The SDK
284
- // handles compaction internally; we just let it run.
314
+ throw err;
285
315
  }
286
316
  logger.info({
287
317
  sessionKey: opts.sessionKey,
@@ -6949,7 +6949,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6949
6949
  }
6950
6950
  else if (preset === 'uncapped' || preset === 'off' || preset === 'none') {
6951
6951
  writes = DASHBOARD_BUDGET_ROWS.map(row => ({ key: row.key, value: '0' }));
6952
- message = 'Removed spend caps by setting all budget values to 0. This does not change 1M context mode; use Force 200K or Safe Recovery for 1M errors. Restart Clementine to apply to running workers.';
6952
+ message = 'Removed spend caps by setting all budget values to 0. Restart Clementine for the change to take effect on running workers. (1M context mode is separate — use Force 200K or Safe Recovery for 1M errors.)';
6953
6953
  }
6954
6954
  else {
6955
6955
  res.status(400).json({ error: 'preset must be defaults or uncapped' });
@@ -10,7 +10,7 @@ import pino from 'pino';
10
10
  import { oneMillionContextRecoveryMessage, PersonalAssistant, } from '../agent/assistant.js';
11
11
  import { runWithTrace, logAuditJsonl } from '../agent/hooks.js';
12
12
  import { SelfImproveLoop } from '../agent/self-improve.js';
13
- import { MODELS, AGENTS_DIR, TEAM_COMMS_LOG, BASE_DIR, SEEN_CHANNELS_FILE, AUTO_DELEGATE_ENABLED, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, } from '../config.js';
13
+ import { MODELS, BUDGET, AGENTS_DIR, TEAM_COMMS_LOG, BASE_DIR, SEEN_CHANNELS_FILE, AUTO_DELEGATE_ENABLED, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, } from '../config.js';
14
14
  import { scanner } from '../security/scanner.js';
15
15
  import { lanes } from './lanes.js';
16
16
  import { AgentManager } from '../agent/agent-manager.js';
@@ -1820,7 +1820,13 @@ export class Gateway {
1820
1820
  // Builder cost knobs: Haiku is plenty for JSON drafting,
1821
1821
  // tight budget, no tools surfaced in the system prompt.
1822
1822
  const builderModel = isBuilderSession ? MODELS.haiku : effectiveModel;
1823
- const builderBudget = isBuilderSession ? 0.10 : undefined;
1823
+ // Builder stays tight ($0.10 Haiku JSON drafting only).
1824
+ // Regular chat reads BUDGET.chat from config (env / clementine.json /
1825
+ // dashboard writes). 0 = uncapped — the runAgent layer omits the
1826
+ // SDK option entirely in that case.
1827
+ const chatBudget = isBuilderSession
1828
+ ? 0.10
1829
+ : (BUDGET.chat > 0 ? BUDGET.chat : undefined);
1824
1830
  const builderAllowedTools = isBuilderSession ? [] : undefined;
1825
1831
  logger.info({
1826
1832
  sessionKey: effectiveSessionKey,
@@ -1841,7 +1847,7 @@ export class Gateway {
1841
1847
  memoryStore: this.assistant.getMemoryStore?.() ?? null,
1842
1848
  ...(builderModel ? { model: builderModel } : {}),
1843
1849
  ...(maxTurns ? { maxTurns } : {}),
1844
- ...(builderBudget !== undefined ? { maxBudgetUsd: builderBudget } : {}),
1850
+ ...(chatBudget !== undefined ? { maxBudgetUsd: chatBudget } : {}),
1845
1851
  ...(builderAllowedTools ? { allowedTools: builderAllowedTools } : {}),
1846
1852
  ...(chatSystemAppend ? { systemPromptAppend: chatSystemAppend } : {}),
1847
1853
  ...(priorSdkSessionId ? { resumeSessionId: priorSdkSessionId } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.59",
3
+ "version": "1.18.60",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",