@dotsetlabs/dotclaw 2.0.0 → 2.1.0

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.
Files changed (101) hide show
  1. package/README.md +2 -4
  2. package/config-examples/runtime.json +16 -122
  3. package/config-examples/tool-policy.json +2 -15
  4. package/container/agent-runner/src/agent-config.ts +9 -43
  5. package/container/agent-runner/src/container-protocol.ts +3 -10
  6. package/container/agent-runner/src/index.ts +207 -453
  7. package/container/agent-runner/src/ipc.ts +0 -62
  8. package/container/agent-runner/src/tools.ts +2 -260
  9. package/dist/agent-execution.d.ts +4 -10
  10. package/dist/agent-execution.d.ts.map +1 -1
  11. package/dist/agent-execution.js +14 -22
  12. package/dist/agent-execution.js.map +1 -1
  13. package/dist/config.d.ts +1 -2
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -2
  16. package/dist/config.js.map +1 -1
  17. package/dist/container-protocol.d.ts +3 -10
  18. package/dist/container-protocol.d.ts.map +1 -1
  19. package/dist/dashboard.d.ts +0 -6
  20. package/dist/dashboard.d.ts.map +1 -1
  21. package/dist/dashboard.js +1 -55
  22. package/dist/dashboard.js.map +1 -1
  23. package/dist/db.d.ts +1 -46
  24. package/dist/db.d.ts.map +1 -1
  25. package/dist/db.js +0 -192
  26. package/dist/db.js.map +1 -1
  27. package/dist/error-messages.d.ts.map +1 -1
  28. package/dist/error-messages.js +5 -1
  29. package/dist/error-messages.js.map +1 -1
  30. package/dist/index.js +6 -52
  31. package/dist/index.js.map +1 -1
  32. package/dist/ipc-dispatcher.d.ts.map +1 -1
  33. package/dist/ipc-dispatcher.js +0 -183
  34. package/dist/ipc-dispatcher.js.map +1 -1
  35. package/dist/maintenance.d.ts.map +1 -1
  36. package/dist/maintenance.js +3 -13
  37. package/dist/maintenance.js.map +1 -1
  38. package/dist/message-pipeline.d.ts +1 -1
  39. package/dist/message-pipeline.d.ts.map +1 -1
  40. package/dist/message-pipeline.js +146 -370
  41. package/dist/message-pipeline.js.map +1 -1
  42. package/dist/metrics.d.ts +0 -3
  43. package/dist/metrics.d.ts.map +1 -1
  44. package/dist/metrics.js +2 -33
  45. package/dist/metrics.js.map +1 -1
  46. package/dist/providers/discord/discord-provider.d.ts.map +1 -1
  47. package/dist/providers/discord/discord-provider.js +6 -3
  48. package/dist/providers/discord/discord-provider.js.map +1 -1
  49. package/dist/request-router.d.ts +9 -31
  50. package/dist/request-router.d.ts.map +1 -1
  51. package/dist/request-router.js +12 -128
  52. package/dist/request-router.js.map +1 -1
  53. package/dist/runtime-config.d.ts +15 -101
  54. package/dist/runtime-config.d.ts.map +1 -1
  55. package/dist/runtime-config.js +37 -159
  56. package/dist/runtime-config.js.map +1 -1
  57. package/dist/streaming.d.ts +58 -0
  58. package/dist/streaming.d.ts.map +1 -0
  59. package/dist/streaming.js +196 -0
  60. package/dist/streaming.js.map +1 -0
  61. package/dist/task-scheduler.d.ts.map +1 -1
  62. package/dist/task-scheduler.js +9 -47
  63. package/dist/task-scheduler.js.map +1 -1
  64. package/dist/tool-policy.d.ts.map +1 -1
  65. package/dist/tool-policy.js +0 -10
  66. package/dist/tool-policy.js.map +1 -1
  67. package/dist/types.d.ts +0 -41
  68. package/dist/types.d.ts.map +1 -1
  69. package/package.json +1 -2
  70. package/scripts/configure.js +81 -0
  71. package/scripts/install.sh +1 -1
  72. package/config-examples/plugin-http.json +0 -18
  73. package/container/skills/agent-browser.md +0 -159
  74. package/dist/background-job-classifier.d.ts +0 -20
  75. package/dist/background-job-classifier.d.ts.map +0 -1
  76. package/dist/background-job-classifier.js +0 -145
  77. package/dist/background-job-classifier.js.map +0 -1
  78. package/dist/background-jobs.d.ts +0 -56
  79. package/dist/background-jobs.d.ts.map +0 -1
  80. package/dist/background-jobs.js +0 -559
  81. package/dist/background-jobs.js.map +0 -1
  82. package/dist/orchestration.d.ts +0 -39
  83. package/dist/orchestration.d.ts.map +0 -1
  84. package/dist/orchestration.js +0 -136
  85. package/dist/orchestration.js.map +0 -1
  86. package/dist/planner-probe.d.ts +0 -14
  87. package/dist/planner-probe.d.ts.map +0 -1
  88. package/dist/planner-probe.js +0 -97
  89. package/dist/planner-probe.js.map +0 -1
  90. package/dist/tool-intent-probe.d.ts +0 -11
  91. package/dist/tool-intent-probe.d.ts.map +0 -1
  92. package/dist/tool-intent-probe.js +0 -63
  93. package/dist/tool-intent-probe.js.map +0 -1
  94. package/dist/workflow-engine.d.ts +0 -51
  95. package/dist/workflow-engine.d.ts.map +0 -1
  96. package/dist/workflow-engine.js +0 -281
  97. package/dist/workflow-engine.js.map +0 -1
  98. package/dist/workflow-store.d.ts +0 -39
  99. package/dist/workflow-store.d.ts.map +0 -1
  100. package/dist/workflow-store.js +0 -173
  101. package/dist/workflow-store.js.map +0 -1
@@ -59,10 +59,31 @@ function getCachedOpenRouter(apiKey: string, options: ReturnType<typeof getOpenR
59
59
  if (cachedOpenRouter && cachedOpenRouterKey === apiKey && cachedOpenRouterOptions === optionsKey) {
60
60
  return cachedOpenRouter;
61
61
  }
62
- cachedOpenRouter = new OpenRouter({
62
+ const client = new OpenRouter({
63
63
  apiKey,
64
64
  ...options
65
65
  });
66
+
67
+ // The SDK accepts httpReferer/xTitle in the constructor but never injects
68
+ // them as HTTP headers in the Responses API path (betaResponsesSend).
69
+ // Wrap callModel to inject them on every request.
70
+ const { httpReferer, xTitle } = options;
71
+ if (httpReferer || xTitle) {
72
+ const extraHeaders: Record<string, string> = {};
73
+ if (httpReferer) extraHeaders['HTTP-Referer'] = httpReferer;
74
+ if (xTitle) extraHeaders['X-Title'] = xTitle;
75
+
76
+ const originalCallModel = client.callModel.bind(client);
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ client.callModel = (request: any, opts?: any) => {
79
+ return originalCallModel(request, {
80
+ ...opts,
81
+ headers: { ...extraHeaders, ...(opts?.headers as Record<string, string>) }
82
+ });
83
+ };
84
+ }
85
+
86
+ cachedOpenRouter = client;
66
87
  cachedOpenRouterKey = apiKey;
67
88
  cachedOpenRouterOptions = optionsKey;
68
89
  return cachedOpenRouter;
@@ -72,18 +93,30 @@ function log(message: string): void {
72
93
  console.error(`[agent-runner] ${message}`);
73
94
  }
74
95
 
96
+ function classifyError(err: unknown): 'retryable' | null {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ const lower = msg.toLowerCase();
99
+ if (/429|rate.?limit/.test(lower)) return 'retryable';
100
+ if (/\b5\d{2}\b/.test(msg) || /server error|bad gateway|unavailable/.test(lower)) return 'retryable';
101
+ if (/timeout|timed out|deadline/.test(lower)) return 'retryable';
102
+ if (/model.?not.?available|no endpoints|provider error/.test(lower)) return 'retryable';
103
+ return null;
104
+ }
105
+
75
106
  // ── Response text extraction ─────────────────────────────────────────
76
107
 
77
- async function getResponseText(result: OpenRouterResult, context: string): Promise<string> {
108
+ async function getResponseText(result: OpenRouterResult, context: string): Promise<{ text: string; error?: string }> {
78
109
  try {
79
110
  const text = await result.getText();
80
111
  if (typeof text === 'string' && text.trim()) {
81
- return text;
112
+ return { text };
82
113
  }
83
114
  } catch (err) {
84
- log(`getText failed (${context}): ${err instanceof Error ? err.message : String(err)}`);
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ log(`getText failed (${context}): ${message}`);
117
+ return { text: '', error: message };
85
118
  }
86
- return '';
119
+ return { text: '' };
87
120
  }
88
121
 
89
122
  function writeOutput(output: ContainerOutput): void {
@@ -199,62 +232,6 @@ function getConfig(config: ReturnType<typeof loadAgentConfig>): MemoryConfig & {
199
232
  };
200
233
  }
201
234
 
202
- function buildPlannerPrompt(messages: Message[]): { instructions: string; input: string } {
203
- const transcript = messages.map(msg => `${msg.role.toUpperCase()}: ${msg.content}`).join('\n\n');
204
- const instructions = [
205
- 'You are a planning module for a personal assistant.',
206
- 'Given the conversation, produce a concise plan in JSON.',
207
- 'Return JSON only with keys:',
208
- '- steps: array of short action steps',
209
- '- tools: array of tool names you expect to use (if any)',
210
- '- risks: array of potential pitfalls or missing info',
211
- '- questions: array of clarifying questions (if any)',
212
- 'Keep each array short. Use empty arrays if not needed.'
213
- ].join('\n');
214
- const input = `Conversation:\n${transcript}`;
215
- return { instructions, input };
216
- }
217
-
218
- function parsePlannerResponse(text: string): { steps: string[]; tools: string[]; risks: string[]; questions: string[] } | null {
219
- const trimmed = text.trim();
220
- let jsonText = trimmed;
221
- const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
222
- if (fenceMatch) {
223
- jsonText = fenceMatch[1].trim();
224
- }
225
- try {
226
- const parsed = JSON.parse(jsonText) as Record<string, unknown>;
227
- const steps = Array.isArray(parsed.steps) ? parsed.steps.filter(item => typeof item === 'string') : [];
228
- const tools = Array.isArray(parsed.tools) ? parsed.tools.filter(item => typeof item === 'string') : [];
229
- const risks = Array.isArray(parsed.risks) ? parsed.risks.filter(item => typeof item === 'string') : [];
230
- const questions = Array.isArray(parsed.questions) ? parsed.questions.filter(item => typeof item === 'string') : [];
231
- return { steps, tools, risks, questions };
232
- } catch {
233
- return null;
234
- }
235
- }
236
-
237
- function formatPlanBlock(plan: { steps: string[]; tools: string[]; risks: string[]; questions: string[] }): string {
238
- const lines: string[] = ['Planned approach (planner):'];
239
- if (plan.steps.length > 0) {
240
- lines.push('Steps:');
241
- for (const step of plan.steps) lines.push(`- ${step}`);
242
- }
243
- if (plan.tools.length > 0) {
244
- lines.push('Tools:');
245
- for (const tool of plan.tools) lines.push(`- ${tool}`);
246
- }
247
- if (plan.risks.length > 0) {
248
- lines.push('Risks:');
249
- for (const risk of plan.risks) lines.push(`- ${risk}`);
250
- }
251
- if (plan.questions.length > 0) {
252
- lines.push('Questions:');
253
- for (const question of plan.questions) lines.push(`- ${question}`);
254
- }
255
- return lines.join('\n');
256
- }
257
-
258
235
  function getOpenRouterOptions(config: ReturnType<typeof loadAgentConfig>) {
259
236
  const timeoutMs = config.agent.openrouter.timeoutMs;
260
237
  const retryEnabled = config.agent.openrouter.retry;
@@ -319,16 +296,6 @@ function estimateMessagesTokens(messages: Message[], tokensPerChar: number, toke
319
296
 
320
297
  const MEMORY_SUMMARY_MAX_CHARS = 2000;
321
298
 
322
- const compactToolsDoc = [
323
- 'Tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, GitClone, NpmInstall,',
324
- 'mcp__dotclaw__send_message, send_file, send_photo, send_voice, send_audio, send_location,',
325
- 'send_contact, send_poll, send_buttons, edit_message, delete_message, download_url,',
326
- 'schedule_task, run_task, list_tasks, pause_task, resume_task, cancel_task, update_task,',
327
- 'spawn_job, job_status, list_jobs, cancel_job, job_update, register_group, remove_group,',
328
- 'list_groups, set_model, memory_upsert, memory_search, memory_list, memory_forget, memory_stats.',
329
- 'plugin__* (plugins), mcp_ext__* (external MCP).'
330
- ].join('\n');
331
-
332
299
  function buildSystemInstructions(params: {
333
300
  assistantName: string;
334
301
  groupNotes?: string | null;
@@ -344,47 +311,33 @@ function buildSystemInstructions(params: {
344
311
  toolReliability?: Array<{ name: string; success_rate: number; count: number; avg_duration_ms: number | null }>;
345
312
  behaviorConfig?: Record<string, unknown>;
346
313
  isScheduledTask: boolean;
347
- isBackgroundTask: boolean;
348
314
  taskId?: string;
349
- isBackgroundJob: boolean;
350
- jobId?: string;
351
315
  timezone?: string;
352
316
  hostPlatform?: string;
353
317
  messagingPlatform?: string;
354
- planBlock?: string;
355
- profile?: 'fast' | 'standard' | 'deep' | 'background';
356
318
  taskExtractionPack?: PromptPack | null;
357
319
  responseQualityPack?: PromptPack | null;
358
320
  toolCallingPack?: PromptPack | null;
359
321
  toolOutcomePack?: PromptPack | null;
360
322
  memoryPolicyPack?: PromptPack | null;
361
323
  memoryRecallPack?: PromptPack | null;
324
+ maxToolSteps?: number;
362
325
  }): string {
363
- const profile = params.profile || 'standard';
364
- const isFast = profile === 'fast';
365
-
366
- // Tool descriptions are already in each tool's schema — only add essential guidance here
367
- const toolGuidance = isFast ? '' : [
326
+ const toolGuidance = [
368
327
  'Key tool rules:',
369
328
  '- User attachments arrive in /workspace/group/inbox/ (see <attachment> tags). Process with Read/Bash/Python.',
370
- '- To send media from the web: download_url → send_photo/send_file/send_audio. Always foreground.',
329
+ '- To send media from the web: download_url → send_photo/send_file/send_audio.',
371
330
  '- Charts/plots: matplotlib → savefig → send_photo. Graphviz → dot -Tpng → send_photo.',
372
331
  '- Voice messages are auto-transcribed (<transcript> in <attachment>). Reply with normal text — the host auto-converts to voice.',
373
332
  '- GitHub CLI (`gh`) is available if GH_TOKEN is set.',
374
- '- Use spawn_job ONLY for tasks >2 minutes (deep research, large projects). Everything else: foreground.',
375
- '- When you spawn a job, reply minimally (e.g. "Working on it"). No job IDs, bullet plans, or status offers.',
376
333
  '- plugin__* and mcp_ext__* tools may be available if configured.'
377
334
  ].join('\n');
378
335
 
379
- const toolsSection = isFast ? compactToolsDoc : toolGuidance;
380
-
381
- const browserAutomation = !isFast && agentConfig.agent.browser.enabled ? [
336
+ const browserAutomation = agentConfig.agent.browser.enabled ? [
382
337
  'Browser Tool: actions: navigate, snapshot, click, fill, screenshot, extract, evaluate, close.',
383
338
  'Use snapshot with interactive=true for clickable refs (@e1, @e2). Screenshots → /workspace/group/screenshots/.'
384
339
  ].join('\n') : '';
385
340
 
386
- // Memory section: skip for fast profile, consolidate empty placeholders for other profiles
387
- const includeMemory = !isFast;
388
341
  const hasAnyMemory = params.memorySummary || params.memoryFacts.length > 0 ||
389
342
  params.longTermRecall.length > 0 || params.userProfile;
390
343
 
@@ -415,7 +368,7 @@ function buildSystemInstructions(params: {
415
368
  const globalNotes = params.globalNotes ? `Global notes:\n${params.globalNotes}` : '';
416
369
  const skillNotes = params.skillCatalog ? formatSkillCatalog(params.skillCatalog) : '';
417
370
 
418
- const toolReliability = !isFast && params.toolReliability && params.toolReliability.length > 0
371
+ const toolReliability = params.toolReliability && params.toolReliability.length > 0
419
372
  ? params.toolReliability
420
373
  .sort((a, b) => a.success_rate - b.success_rate)
421
374
  .slice(0, 20)
@@ -464,23 +417,13 @@ function buildSystemInstructions(params: {
464
417
  const scheduledNote = params.isScheduledTask
465
418
  ? `You are running as a scheduled task${params.taskId ? ` (task id: ${params.taskId})` : ''}. If you need to communicate, use \`mcp__dotclaw__send_message\`.`
466
419
  : '';
467
- const backgroundNote = params.isBackgroundTask
468
- ? 'You are running in the background for a user request. Focus on completing the task and return a complete response without asking follow-up questions unless strictly necessary.'
469
- : '';
470
- const jobNote = params.isBackgroundJob
471
- ? `You are running as a background job${params.jobId ? ` (job id: ${params.jobId})` : ''}. Complete the task silently and return the result. Do NOT call \`mcp__dotclaw__job_update\` for routine progress — only for critical blockers or required user decisions. Do NOT send messages to the chat about your progress. Just do the work and return the final result. The system will deliver your result to the user automatically.`
472
- : '';
473
- const jobArtifactsNote = params.isBackgroundJob && params.jobId
474
- ? `Job artifacts directory: /workspace/group/jobs/${params.jobId}`
475
- : '';
476
420
 
477
421
  const fmtPack = (label: string, pack: PromptPack | null | undefined) =>
478
422
  pack ? formatPromptPack({ label, pack, maxDemos: PROMPT_PACKS_MAX_DEMOS, maxChars: PROMPT_PACKS_MAX_CHARS }) : '';
479
423
 
480
- // Skip prompt packs for fast profile; enforce aggregate budget for others
481
- const PROMPT_PACKS_TOTAL_BUDGET = PROMPT_PACKS_MAX_CHARS * 3; // Aggregate cap across all packs
424
+ const PROMPT_PACKS_TOTAL_BUDGET = PROMPT_PACKS_MAX_CHARS * 3;
482
425
  const allPackBlocks: string[] = [];
483
- if (!isFast) {
426
+ {
484
427
  const packEntries: Array<[string, PromptPack | null | undefined]> = [
485
428
  ['Tool Calling Guidelines', params.toolCallingPack],
486
429
  ['Tool Outcome Guidelines', params.toolOutcomePack],
@@ -505,9 +448,8 @@ function buildSystemInstructions(params: {
505
448
  const memoryPolicyBlock = allPackBlocks.find(b => b.includes('Memory Policy')) || '';
506
449
  const memoryRecallBlock = allPackBlocks.find(b => b.includes('Memory Recall')) || '';
507
450
 
508
- // Build memory sections — omit entirely for fast, consolidate empty placeholders for others
509
451
  const memorySections: string[] = [];
510
- if (includeMemory) {
452
+ {
511
453
  if (hasAnyMemory) {
512
454
  if (memorySummary) {
513
455
  memorySections.push('Long-term memory summary:', memorySummary);
@@ -538,16 +480,12 @@ function buildSystemInstructions(params: {
538
480
  `You are ${params.assistantName}, a personal assistant running inside DotClaw.${params.messagingPlatform ? ` You are currently connected via ${params.messagingPlatform}.` : ''}`,
539
481
  hostPlatformNote,
540
482
  scheduledNote,
541
- backgroundNote,
542
- jobNote,
543
- jobArtifactsNote,
544
- toolsSection,
483
+ toolGuidance,
545
484
  browserAutomation,
546
485
  groupNotes,
547
486
  globalNotes,
548
487
  skillNotes,
549
488
  timezoneNote,
550
- params.planBlock || '',
551
489
  toolCallingBlock,
552
490
  toolOutcomeBlock,
553
491
  taskExtractionBlock,
@@ -558,6 +496,9 @@ function buildSystemInstructions(params: {
558
496
  availableGroups ? `Available groups (main group only):\n${availableGroups}` : '',
559
497
  toolReliability ? `Tool reliability (recent):\n${toolReliability}` : '',
560
498
  behaviorNotes.length > 0 ? `Behavior notes:\n${behaviorNotes.join('\n')}` : '',
499
+ params.maxToolSteps
500
+ ? `You have a budget of ${params.maxToolSteps} tool steps per request. If a task is large, break your work into phases and always finish with a text summary of what you accomplished — never end on a tool call without a response.`
501
+ : '',
561
502
  'Be concise and helpful. When you use tools, summarize what happened rather than dumping raw output.'
562
503
  ].filter(Boolean).join('\n\n');
563
504
  }
@@ -613,6 +554,47 @@ function decodeXml(value: string): string {
613
554
  .replace(/&amp;/g, '&');
614
555
  }
615
556
 
557
+ // ── Image/Vision support ──────────────────────────────────────────────
558
+
559
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB per image
560
+ const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024; // 20MB total across all images
561
+ const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
562
+
563
+ function loadImageAttachments(attachments?: ContainerInput['attachments']): Array<{
564
+ type: 'image_url';
565
+ image_url: { url: string };
566
+ }> {
567
+ if (!attachments) return [];
568
+ const images: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
569
+ let totalBytes = 0;
570
+ for (const att of attachments) {
571
+ if (att.type !== 'photo') continue;
572
+ const mime = att.mime_type || 'image/jpeg';
573
+ if (!IMAGE_MIME_TYPES.has(mime)) continue;
574
+ try {
575
+ const stat = fs.statSync(att.path);
576
+ if (stat.size > MAX_IMAGE_BYTES) {
577
+ log(`Skipping image ${att.path}: ${stat.size} bytes exceeds ${MAX_IMAGE_BYTES}`);
578
+ continue;
579
+ }
580
+ if (totalBytes + stat.size > MAX_TOTAL_IMAGE_BYTES) {
581
+ log(`Skipping image ${att.path}: cumulative size would exceed ${MAX_TOTAL_IMAGE_BYTES}`);
582
+ break;
583
+ }
584
+ const data = fs.readFileSync(att.path);
585
+ totalBytes += data.length;
586
+ const b64 = data.toString('base64');
587
+ images.push({
588
+ type: 'image_url',
589
+ image_url: { url: `data:${mime};base64,${b64}` }
590
+ });
591
+ } catch (err) {
592
+ log(`Failed to load image ${att.path}: ${err instanceof Error ? err.message : err}`);
593
+ }
594
+ }
595
+ return images;
596
+ }
597
+
616
598
  function messagesToOpenRouter(messages: Message[]) {
617
599
  return messages.map(message => ({
618
600
  role: message.role,
@@ -655,7 +637,7 @@ async function updateMemorySummary(params: {
655
637
  temperature: 0.1,
656
638
  reasoning: { effort: 'low' as const }
657
639
  });
658
- const text = await getResponseText(result, 'summary');
640
+ const { text } = await getResponseText(result, 'summary');
659
641
  return parseSummaryResponse(text);
660
642
  }
661
643
 
@@ -729,122 +711,6 @@ function parseMemoryExtraction(text: string): Array<Record<string, unknown>> {
729
711
  }
730
712
  }
731
713
 
732
- type ResponseValidation = {
733
- verdict: 'pass' | 'fail';
734
- issues: string[];
735
- missing: string[];
736
- };
737
-
738
- function buildResponseValidationPrompt(params: { userPrompt: string; response: string }): { instructions: string; input: string } {
739
- const instructions = [
740
- 'You are a strict response quality checker.',
741
- 'Given a user request and an assistant response, decide if the response fully addresses the request.',
742
- 'Fail if the response is empty, generic, deflects, promises work without results, or ignores any explicit questions.',
743
- 'Pass only if the response directly answers all parts with concrete, relevant content.',
744
- 'Return JSON only with keys: verdict ("pass"|"fail"), issues (array of strings), missing (array of strings).'
745
- ].join('\n');
746
-
747
- const input = [
748
- 'User request:',
749
- params.userPrompt,
750
- '',
751
- 'Assistant response:',
752
- params.response
753
- ].join('\n');
754
-
755
- return { instructions, input };
756
- }
757
-
758
- function parseResponseValidation(text: string): ResponseValidation | null {
759
- const trimmed = text.trim();
760
- let jsonText = trimmed;
761
- const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
762
- if (fenceMatch) {
763
- jsonText = fenceMatch[1].trim();
764
- }
765
- try {
766
- const parsed = JSON.parse(jsonText);
767
- const verdict = parsed?.verdict;
768
- if (verdict !== 'pass' && verdict !== 'fail') return null;
769
- const issues = Array.isArray(parsed?.issues)
770
- ? parsed.issues.filter((issue: unknown) => typeof issue === 'string')
771
- : [];
772
- const missing = Array.isArray(parsed?.missing)
773
- ? parsed.missing.filter((item: unknown) => typeof item === 'string')
774
- : [];
775
- return { verdict, issues, missing };
776
- } catch {
777
- return null;
778
- }
779
- }
780
-
781
- async function validateResponseQuality(params: {
782
- openrouter: OpenRouter;
783
- model: string;
784
- userPrompt: string;
785
- response: string;
786
- maxOutputTokens: number;
787
- temperature: number;
788
- }): Promise<ResponseValidation | null> {
789
- const prompt = buildResponseValidationPrompt({
790
- userPrompt: params.userPrompt,
791
- response: params.response
792
- });
793
- const result = await params.openrouter.callModel({
794
- model: params.model,
795
- instructions: prompt.instructions,
796
- input: prompt.input,
797
- maxOutputTokens: params.maxOutputTokens,
798
- temperature: params.temperature,
799
- reasoning: { effort: 'low' as const }
800
- });
801
- const text = await getResponseText(result, 'response_validation');
802
- return parseResponseValidation(text);
803
- }
804
-
805
- function buildRetryGuidance(validation: ResponseValidation | null): string {
806
- const issues = validation?.issues || [];
807
- const missing = validation?.missing || [];
808
- const points = [...issues, ...missing].filter(Boolean).slice(0, 8);
809
- const details = points.length > 0
810
- ? points.map(item => `- ${item}`).join('\n')
811
- : '- The previous response did not fully address the request.';
812
- return [
813
- 'IMPORTANT: Your previous response did not fully answer the user request.',
814
- 'Provide a direct, complete answer now. Do not mention this retry.',
815
- 'Issues to fix:',
816
- details
817
- ].join('\n');
818
- }
819
-
820
- function buildPlannerTrigger(pattern: string | undefined): RegExp | null {
821
- if (!pattern) return null;
822
- try {
823
- return new RegExp(pattern, 'i');
824
- } catch {
825
- return null;
826
- }
827
- }
828
-
829
- function shouldRunPlanner(params: {
830
- enabled: boolean;
831
- mode: string;
832
- prompt: string;
833
- tokensPerChar: number;
834
- minTokens: number;
835
- trigger: RegExp | null;
836
- }): boolean {
837
- if (!params.enabled) return false;
838
- const mode = params.mode.toLowerCase();
839
- if (mode === 'always') return true;
840
- if (mode === 'off') return false;
841
-
842
- const estimatedTokens = estimateTokensForModel(params.prompt, params.tokensPerChar);
843
- if (params.minTokens > 0 && estimatedTokens >= params.minTokens) return true;
844
- if (params.trigger && params.trigger.test(params.prompt)) return true;
845
- return false;
846
- }
847
-
848
714
  export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutput> {
849
715
  log(`Received input for group: ${input.groupFolder}`);
850
716
 
@@ -868,7 +734,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
868
734
  config.compactionTriggerTokens = Math.max(1000, Math.min(config.compactionTriggerTokens, compactionTarget));
869
735
  }
870
736
  if (input.modelMaxOutputTokens && Number.isFinite(input.modelMaxOutputTokens)) {
871
- config.maxOutputTokens = Math.min(config.maxOutputTokens, input.modelMaxOutputTokens);
737
+ config.maxOutputTokens = input.modelMaxOutputTokens;
872
738
  }
873
739
  if (input.modelTemperature && Number.isFinite(input.modelTemperature)) {
874
740
  config.temperature = input.modelTemperature;
@@ -877,30 +743,12 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
877
743
  const maxToolSteps = Number.isFinite(input.maxToolSteps)
878
744
  ? Math.max(1, Math.floor(input.maxToolSteps as number))
879
745
  : agent.tools.maxToolSteps;
880
- const memoryExtractionEnabled = agent.memory.extraction.enabled && !input.disableMemoryExtraction;
746
+ const memoryExtractionEnabled = agent.memory.extraction.enabled;
881
747
  const isDaemon = process.env.DOTCLAW_DAEMON === '1';
882
- const memoryExtractionAsync = agent.memory.extraction.async;
883
748
  const memoryExtractionMaxMessages = agent.memory.extraction.maxMessages;
884
749
  const memoryExtractionMaxOutputTokens = agent.memory.extraction.maxOutputTokens;
885
750
  const memoryExtractScheduled = agent.memory.extractScheduled;
886
751
  const memoryArchiveSync = agent.memory.archiveSync;
887
- const plannerEnabled = agent.planner.enabled && !input.disablePlanner;
888
- const plannerMode = String(agent.planner.mode || 'auto').toLowerCase();
889
- const plannerMinTokens = agent.planner.minTokens;
890
- const plannerTrigger = buildPlannerTrigger(agent.planner.triggerRegex);
891
- const plannerModel = agent.models.planner;
892
- const plannerMaxOutputTokens = agent.planner.maxOutputTokens;
893
- const plannerTemperature = agent.planner.temperature;
894
- const responseValidateEnabled = agent.responseValidation.enabled && !input.disableResponseValidation;
895
- const responseValidateModel = agent.models.responseValidation;
896
- const responseValidateMaxOutputTokens = agent.responseValidation.maxOutputTokens;
897
- const responseValidateTemperature = agent.responseValidation.temperature;
898
- const responseValidateMaxRetries = Number.isFinite(input.responseValidationMaxRetries)
899
- ? Math.max(0, Math.floor(input.responseValidationMaxRetries as number))
900
- : agent.responseValidation.maxRetries;
901
- const responseValidateAllowToolCalls = agent.responseValidation.allowToolCalls;
902
- const responseValidateMinPromptTokens = agent.responseValidation.minPromptTokens || 0;
903
- const responseValidateMinResponseTokens = agent.responseValidation.minResponseTokens || 0;
904
752
  const maxContextMessageTokens = agent.context.maxContextMessageTokens;
905
753
 
906
754
  const openrouter = getCachedOpenRouter(apiKey, openrouterOptions);
@@ -917,7 +765,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
917
765
  const toolCalls: ToolCallRecord[] = [];
918
766
  let memoryItemsUpserted = 0;
919
767
  let memoryItemsExtracted = 0;
920
- const timings: { planner_ms?: number; response_validation_ms?: number; memory_extraction_ms?: number; tool_ms?: number } = {};
768
+ const timings: { memory_extraction_ms?: number; tool_ms?: number } = {};
921
769
  const ipc = createIpcHandlers({
922
770
  chatJid: input.chatJid,
923
771
  groupFolder: input.groupFolder,
@@ -931,11 +779,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
931
779
  onToolCall: (call) => {
932
780
  toolCalls.push(call);
933
781
  },
934
- policy: input.toolPolicy,
935
- jobProgress: {
936
- jobId: input.jobId,
937
- enabled: Boolean(input.isBackgroundJob)
938
- }
782
+ policy: input.toolPolicy
939
783
  });
940
784
 
941
785
  // Discover MCP external tools if enabled
@@ -988,6 +832,14 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
988
832
  }
989
833
  }
990
834
 
835
+ // Resolve reasoning effort: input override > agent config > 'low'
836
+ const VALID_EFFORTS = ['off', 'low', 'medium', 'high'] as const;
837
+ const rawEffort = input.reasoningEffort || agent.reasoning?.effort || 'low';
838
+ const reasoningEffort = VALID_EFFORTS.includes(rawEffort as typeof VALID_EFFORTS[number]) ? rawEffort : 'low';
839
+ const resolvedReasoning = reasoningEffort === 'off'
840
+ ? undefined
841
+ : { effort: reasoningEffort as 'low' | 'medium' | 'high' };
842
+
991
843
  let prompt = input.prompt;
992
844
  if (input.isScheduledTask) {
993
845
  prompt = `[SCHEDULED TASK - You are running automatically, not in response to a user message. Use mcp__dotclaw__send_message if needed to communicate with the user.]\n\n${input.prompt}`;
@@ -1128,7 +980,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1128
980
  if (memoryPolicyResult) promptPackVersions['memory-policy'] = memoryPolicyResult.pack.version;
1129
981
  if (memoryRecallResult) promptPackVersions['memory-recall'] = memoryRecallResult.pack.version;
1130
982
 
1131
- const buildInstructions = (planBlockValue: string) => buildSystemInstructions({
983
+ const buildInstructions = () => buildSystemInstructions({
1132
984
  assistantName,
1133
985
  groupNotes: claudeNotes.group,
1134
986
  globalNotes: claudeNotes.global,
@@ -1143,75 +995,21 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1143
995
  toolReliability: input.toolReliability,
1144
996
  behaviorConfig: input.behaviorConfig,
1145
997
  isScheduledTask: !!input.isScheduledTask,
1146
- isBackgroundTask: !!input.isBackgroundTask,
1147
998
  taskId: input.taskId,
1148
- isBackgroundJob: !!input.isBackgroundJob,
1149
- jobId: input.jobId,
1150
999
  timezone: typeof input.timezone === 'string' ? input.timezone : undefined,
1151
1000
  hostPlatform: typeof input.hostPlatform === 'string' ? input.hostPlatform : undefined,
1152
1001
  messagingPlatform: input.chatJid?.includes(':') ? input.chatJid.split(':')[0] : undefined,
1153
- planBlock: planBlockValue,
1154
- profile: input.profile,
1155
1002
  taskExtractionPack: taskPackResult?.pack || null,
1156
1003
  responseQualityPack: responseQualityResult?.pack || null,
1157
1004
  toolCallingPack: toolCallingResult?.pack || null,
1158
1005
  toolOutcomePack: toolOutcomeResult?.pack || null,
1159
1006
  memoryPolicyPack: memoryPolicyResult?.pack || null,
1160
- memoryRecallPack: memoryRecallResult?.pack || null
1007
+ memoryRecallPack: memoryRecallResult?.pack || null,
1008
+ maxToolSteps
1161
1009
  });
1162
1010
 
1163
- let planBlock = '';
1164
- let instructions = buildInstructions(planBlock);
1165
- let instructionsTokens = estimateTokensForModel(instructions, tokenEstimate.tokensPerChar);
1166
- let maxContextTokens = Math.max(config.maxContextTokens - config.maxOutputTokens - instructionsTokens, 2000);
1167
- let adjustedContextTokens = Math.max(1000, Math.floor(maxContextTokens * tokenRatio));
1168
- let { recentMessages: plannerContextMessages } = splitRecentHistory(recentMessages, adjustedContextTokens, 6);
1169
- plannerContextMessages = clampContextMessages(plannerContextMessages, tokenEstimate.tokensPerChar, maxContextMessageTokens);
1170
-
1171
- if (shouldRunPlanner({
1172
- enabled: plannerEnabled,
1173
- mode: plannerMode,
1174
- prompt,
1175
- tokensPerChar: tokenEstimate.tokensPerChar,
1176
- minTokens: plannerMinTokens,
1177
- trigger: plannerTrigger
1178
- })) {
1179
- try {
1180
- const plannerStartedAt = Date.now();
1181
- const plannerPrompt = buildPlannerPrompt(plannerContextMessages);
1182
- const plannerResult = await openrouter.callModel({
1183
- model: plannerModel,
1184
- instructions: plannerPrompt.instructions,
1185
- input: plannerPrompt.input,
1186
- maxOutputTokens: plannerMaxOutputTokens,
1187
- temperature: plannerTemperature,
1188
- reasoning: { effort: 'low' as const }
1189
- });
1190
- const plannerText = await getResponseText(plannerResult, 'planner');
1191
- const plan = parsePlannerResponse(plannerText);
1192
- if (plan) {
1193
- planBlock = formatPlanBlock(plan);
1194
- }
1195
- timings.planner_ms = Date.now() - plannerStartedAt;
1196
- } catch (err) {
1197
- log(`Planner failed: ${err instanceof Error ? err.message : String(err)}`);
1198
- }
1199
- }
1200
-
1201
- if (planBlock) {
1202
- instructions = buildInstructions(planBlock);
1203
- instructionsTokens = estimateTokensForModel(instructions, tokenEstimate.tokensPerChar);
1204
- maxContextTokens = Math.max(config.maxContextTokens - config.maxOutputTokens - instructionsTokens, 2000);
1205
- adjustedContextTokens = Math.max(1000, Math.floor(maxContextTokens * tokenRatio));
1206
- ({ recentMessages: plannerContextMessages } = splitRecentHistory(recentMessages, adjustedContextTokens, 6));
1207
- plannerContextMessages = clampContextMessages(plannerContextMessages, tokenEstimate.tokensPerChar, maxContextMessageTokens);
1208
- }
1209
-
1210
- const buildContext = (extraInstruction?: string) => {
1211
- let resolvedInstructions = buildInstructions(planBlock);
1212
- if (extraInstruction) {
1213
- resolvedInstructions = `${resolvedInstructions}\n\n${extraInstruction}`;
1214
- }
1011
+ const buildContext = () => {
1012
+ const resolvedInstructions = buildInstructions();
1215
1013
  const resolvedInstructionTokens = estimateTokensForModel(resolvedInstructions, tokenEstimate.tokensPerChar);
1216
1014
  const resolvedMaxContext = Math.max(config.maxContextTokens - config.maxOutputTokens - resolvedInstructionTokens, 2000);
1217
1015
  const resolvedAdjusted = Math.max(1000, Math.floor(resolvedMaxContext * tokenRatio));
@@ -1227,17 +1025,13 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1227
1025
  let responseText = '';
1228
1026
  let completionTokens = 0;
1229
1027
  let promptTokens = 0;
1230
- let modelToolCalls: Array<{ name: string }> = [];
1231
-
1232
1028
  let latencyMs: number | undefined;
1233
- const runCompletion = async (extraInstruction?: string): Promise<{
1234
- responseText: string;
1235
- completionTokens: number;
1236
- promptTokens: number;
1237
- latencyMs?: number;
1238
- modelToolCalls: Array<{ name: string }>;
1239
- }> => {
1240
- const { instructions: resolvedInstructions, instructionsTokens: resolvedInstructionTokens, contextMessages } = buildContext(extraInstruction);
1029
+
1030
+ const modelChain = [model, ...(input.modelFallbacks || [])].slice(0, 3);
1031
+ let currentModel = model;
1032
+
1033
+ try {
1034
+ const { instructions: resolvedInstructions, instructionsTokens: resolvedInstructionTokens, contextMessages } = buildContext();
1241
1035
  const resolvedPromptTokens = resolvedInstructionTokens
1242
1036
  + estimateMessagesTokens(contextMessages, tokenEstimate.tokensPerChar, tokenEstimate.tokensPerMessage)
1243
1037
  + tokenEstimate.tokensPerRequest;
@@ -1252,113 +1046,107 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1252
1046
  }
1253
1047
  }
1254
1048
 
1255
- log('Starting OpenRouter call...');
1256
- const startedAt = Date.now();
1257
- const callParams = {
1258
- model,
1259
- instructions: resolvedInstructions,
1260
- input: messagesToOpenRouter(contextMessages),
1261
- tools,
1262
- stopWhen: stepCountIs(maxToolSteps),
1263
- maxOutputTokens: config.maxOutputTokens,
1264
- temperature: config.temperature,
1265
- reasoning: { effort: 'low' as const }
1266
- };
1267
- const result = await openrouter.callModel(callParams);
1268
- const localLatencyMs = Date.now() - startedAt;
1269
-
1270
- // Get the complete response text via the SDK's proper getText() path
1271
- let localResponseText = await getResponseText(result, 'completion');
1272
-
1273
- const toolCallsFromModel = await result.getToolCalls();
1274
- if (toolCallsFromModel.length > 0) {
1275
- log(`Model made ${toolCallsFromModel.length} tool call(s): ${toolCallsFromModel.map(t => t.name).join(', ')}`);
1276
- }
1277
-
1278
- if (!localResponseText || !localResponseText.trim()) {
1279
- if (toolCallsFromModel.length > 0) {
1280
- localResponseText = 'I started running tool calls but did not get a final response. If you want me to continue, please ask a narrower subtask or say "continue".';
1281
- } else {
1282
- log(`Warning: Model returned empty/whitespace response. tool calls: ${toolCallsFromModel.length}`);
1049
+ const contextInput = messagesToOpenRouter(contextMessages);
1050
+
1051
+ // Inject vision content into the last user message if images are present
1052
+ const imageContent = loadImageAttachments(input.attachments);
1053
+ if (imageContent.length > 0 && contextInput.length > 0) {
1054
+ const lastMsg = contextInput[contextInput.length - 1];
1055
+ if (lastMsg.role === 'user') {
1056
+ // Convert string content to multi-modal content array
1057
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1058
+ (lastMsg as any).content = [
1059
+ { type: 'text', text: typeof lastMsg.content === 'string' ? lastMsg.content : '' },
1060
+ ...imageContent
1061
+ ];
1283
1062
  }
1284
- } else {
1285
- log(`Model returned text response (${localResponseText.length} chars)`);
1286
1063
  }
1287
1064
 
1288
- const localCompletionTokens = estimateTokensForModel(localResponseText || '', tokenEstimate.tokensPerChar);
1289
- return {
1290
- responseText: localResponseText,
1291
- completionTokens: localCompletionTokens,
1292
- promptTokens: resolvedPromptTokens,
1293
- latencyMs: localLatencyMs,
1294
- modelToolCalls: toolCallsFromModel
1295
- };
1296
- };
1065
+ let lastError: unknown = null;
1066
+ for (let attempt = 0; attempt < modelChain.length; attempt++) {
1067
+ currentModel = modelChain[attempt];
1068
+ if (attempt > 0) log(`Fallback ${attempt}: trying ${currentModel}`);
1069
+
1070
+ try {
1071
+ log(`Starting OpenRouter call (${currentModel})...`);
1072
+ const startedAt = Date.now();
1073
+ const result = openrouter.callModel({
1074
+ model: currentModel,
1075
+ instructions: resolvedInstructions,
1076
+ input: contextInput,
1077
+ tools,
1078
+ stopWhen: stepCountIs(maxToolSteps),
1079
+ maxOutputTokens: config.maxOutputTokens,
1080
+ temperature: config.temperature,
1081
+ reasoning: resolvedReasoning
1082
+ });
1083
+
1084
+ // Stream text chunks to IPC if streamDir is provided
1085
+ if (input.streamDir) {
1086
+ let seq = 0;
1087
+ try {
1088
+ fs.mkdirSync(input.streamDir, { recursive: true });
1089
+ for await (const delta of result.getTextStream()) {
1090
+ seq++;
1091
+ const chunkFile = path.join(input.streamDir, `chunk_${String(seq).padStart(6, '0')}.txt`);
1092
+ const tmpFile = chunkFile + '.tmp';
1093
+ fs.writeFileSync(tmpFile, delta);
1094
+ fs.renameSync(tmpFile, chunkFile);
1095
+ }
1096
+ fs.writeFileSync(path.join(input.streamDir, 'done'), '');
1097
+ } catch (streamErr) {
1098
+ log(`Stream error: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}`);
1099
+ try { fs.writeFileSync(path.join(input.streamDir, 'error'), streamErr instanceof Error ? streamErr.message : String(streamErr)); } catch { /* ignore */ }
1100
+ }
1101
+ }
1297
1102
 
1298
- try {
1299
- const firstAttempt = await runCompletion();
1300
- responseText = firstAttempt.responseText;
1301
- completionTokens = firstAttempt.completionTokens;
1302
- promptTokens = firstAttempt.promptTokens;
1303
- latencyMs = firstAttempt.latencyMs;
1304
- modelToolCalls = firstAttempt.modelToolCalls;
1305
-
1306
- const shouldValidate = responseValidateEnabled
1307
- && promptTokens >= responseValidateMinPromptTokens
1308
- && completionTokens >= responseValidateMinResponseTokens
1309
- && (responseValidateAllowToolCalls || modelToolCalls.length === 0);
1310
- if (shouldValidate) {
1311
- const MAX_VALIDATION_ITERATIONS = 5;
1312
- let retriesLeft = responseValidateMaxRetries;
1313
- for (let _validationIter = 0; _validationIter < MAX_VALIDATION_ITERATIONS; _validationIter++) {
1314
- if (!responseValidateAllowToolCalls && modelToolCalls.length > 0) {
1315
- break;
1103
+ latencyMs = Date.now() - startedAt;
1104
+
1105
+ const completionResult = await getResponseText(result, 'completion');
1106
+ responseText = completionResult.text;
1107
+
1108
+ const toolCallsFromModel = await result.getToolCalls();
1109
+ if (toolCallsFromModel.length > 0) {
1110
+ log(`Model made ${toolCallsFromModel.length} tool call(s): ${toolCallsFromModel.map(t => t.name).join(', ')}`);
1316
1111
  }
1317
- let validationResult: ResponseValidation | null = null;
1318
1112
  if (!responseText || !responseText.trim()) {
1319
- validationResult = { verdict: 'fail', issues: ['Response was empty.'], missing: [] };
1320
- } else {
1321
- try {
1322
- const validationStartedAt = Date.now();
1323
- validationResult = await validateResponseQuality({
1324
- openrouter,
1325
- model: responseValidateModel,
1326
- userPrompt: query,
1327
- response: responseText,
1328
- maxOutputTokens: responseValidateMaxOutputTokens,
1329
- temperature: responseValidateTemperature
1330
- });
1331
- timings.response_validation_ms = (timings.response_validation_ms ?? 0) + (Date.now() - validationStartedAt);
1332
- } catch (err) {
1333
- log(`Response validation failed: ${err instanceof Error ? err.message : String(err)}`);
1113
+ if (completionResult.error) {
1114
+ log(`Tool execution failed: ${completionResult.error}`);
1115
+ responseText = `Something went wrong while processing your request: ${completionResult.error}. Please try again.`;
1116
+ } else if (toolCallsFromModel.length > 0) {
1117
+ responseText = 'I started running tool calls but did not get a final response. If you want me to continue, please ask a narrower subtask or say "continue".';
1118
+ } else {
1119
+ log(`Warning: Model returned empty/whitespace response. tool calls: ${toolCallsFromModel.length}`);
1334
1120
  }
1121
+ } else {
1122
+ log(`Model returned text response (${responseText.length} chars)`);
1335
1123
  }
1336
- if (!validationResult || validationResult.verdict === 'pass') {
1337
- break;
1338
- }
1339
- if (retriesLeft <= 0) {
1340
- break;
1124
+
1125
+ completionTokens = estimateTokensForModel(responseText || '', tokenEstimate.tokensPerChar);
1126
+ promptTokens = resolvedPromptTokens;
1127
+ lastError = null;
1128
+ break; // Success
1129
+ } catch (err) {
1130
+ lastError = err;
1131
+ if (classifyError(err) && attempt < modelChain.length - 1) {
1132
+ log(`${currentModel} failed (${classifyError(err)}): ${err instanceof Error ? err.message : err}`);
1133
+ continue;
1341
1134
  }
1342
- retriesLeft -= 1;
1343
- log(`Response validation failed; retrying (${retriesLeft} retries left)`);
1344
- const retryGuidance = buildRetryGuidance(validationResult);
1345
- const retryAttempt = await runCompletion(retryGuidance);
1346
- responseText = retryAttempt.responseText;
1347
- completionTokens = retryAttempt.completionTokens;
1348
- promptTokens = retryAttempt.promptTokens;
1349
- latencyMs = retryAttempt.latencyMs;
1350
- modelToolCalls = retryAttempt.modelToolCalls;
1135
+ throw err; // Non-retryable or last model — propagate
1351
1136
  }
1352
1137
  }
1138
+
1139
+ if (lastError) throw lastError;
1353
1140
  } catch (err) {
1354
1141
  const errorMessage = err instanceof Error ? err.message : String(err);
1355
- log(`Agent error: ${errorMessage}`);
1142
+ const allFailed = modelChain.length > 1 ? `All models failed. Last error: ${errorMessage}` : errorMessage;
1143
+ log(`Agent error: ${allFailed}`);
1356
1144
  return {
1357
1145
  status: 'error',
1358
1146
  result: null,
1359
1147
  newSessionId: isNew ? sessionCtx.sessionId : undefined,
1360
- error: errorMessage,
1361
- model,
1148
+ error: allFailed,
1149
+ model: currentModel,
1362
1150
  prompt_pack_versions: Object.keys(promptPackVersions).length > 0 ? promptPackVersions : undefined,
1363
1151
  memory_summary: sessionCtx.state.summary,
1364
1152
  memory_facts: sessionCtx.state.facts,
@@ -1375,25 +1163,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1375
1163
  }
1376
1164
 
1377
1165
  appendHistory(sessionCtx, 'assistant', responseText || '');
1378
-
1379
1166
  history = loadHistory(sessionCtx);
1380
- const newMessages = history.filter(m => m.seq > sessionCtx.state.lastSummarySeq);
1381
- if (newMessages.length >= config.summaryUpdateEveryMessages) {
1382
- const summaryUpdate = await updateMemorySummary({
1383
- openrouter,
1384
- model: summaryModel,
1385
- existingSummary: sessionCtx.state.summary,
1386
- existingFacts: sessionCtx.state.facts,
1387
- newMessages,
1388
- maxOutputTokens: config.summaryMaxOutputTokens
1389
- });
1390
- if (summaryUpdate) {
1391
- sessionCtx.state.summary = summaryUpdate.summary;
1392
- sessionCtx.state.facts = summaryUpdate.facts;
1393
- sessionCtx.state.lastSummarySeq = newMessages[newMessages.length - 1].seq;
1394
- saveMemoryState(sessionCtx);
1395
- }
1396
- }
1397
1167
 
1398
1168
  const runMemoryExtraction = async () => {
1399
1169
  const extractionMessages = history.slice(-memoryExtractionMaxMessages);
@@ -1406,7 +1176,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1406
1176
  messages: extractionMessages,
1407
1177
  memoryPolicyPack: memoryPolicyResult?.pack || null
1408
1178
  });
1409
- const extractionResult = await openrouter.callModel({
1179
+ const extractionResult = openrouter.callModel({
1410
1180
  model: memoryModel,
1411
1181
  instructions: extractionPrompt.instructions,
1412
1182
  input: extractionPrompt.input,
@@ -1414,7 +1184,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1414
1184
  temperature: 0.1,
1415
1185
  reasoning: { effort: 'low' as const }
1416
1186
  });
1417
- const extractionText = await getResponseText(extractionResult, 'memory_extraction');
1187
+ const { text: extractionText } = await getResponseText(extractionResult, 'memory_extraction');
1418
1188
  const extractedItems = parseMemoryExtraction(extractionText);
1419
1189
  if (extractedItems.length === 0) return;
1420
1190
 
@@ -1448,27 +1218,11 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1448
1218
  timings.memory_extraction_ms = (timings.memory_extraction_ms ?? 0) + (Date.now() - extractionStartedAt);
1449
1219
  };
1450
1220
 
1451
- if (memoryExtractionEnabled && (!input.isScheduledTask || memoryExtractScheduled)) {
1452
- const runMemoryExtractionWithRetry = async (maxRetries = 2): Promise<void> => {
1453
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1454
- try {
1455
- await runMemoryExtraction();
1456
- return;
1457
- } catch (err) {
1458
- log(`Memory extraction attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
1459
- if (attempt < maxRetries) {
1460
- await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
1461
- }
1462
- }
1463
- }
1464
- log('Memory extraction failed after all retries');
1465
- };
1466
-
1467
- if (memoryExtractionAsync && isDaemon) {
1468
- void runMemoryExtractionWithRetry().catch(() => {});
1469
- } else {
1470
- await runMemoryExtractionWithRetry();
1471
- }
1221
+ if (memoryExtractionEnabled && isDaemon && (!input.isScheduledTask || memoryExtractScheduled)) {
1222
+ // Fire-and-forget in daemon mode; skip entirely in ephemeral mode
1223
+ void runMemoryExtraction().catch((err) => {
1224
+ log(`Memory extraction failed: ${err instanceof Error ? err.message : String(err)}`);
1225
+ });
1472
1226
  }
1473
1227
 
1474
1228
  // Normalize empty/whitespace-only responses to null
@@ -1489,7 +1243,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1489
1243
  status: 'success',
1490
1244
  result: finalResult,
1491
1245
  newSessionId: isNew ? sessionCtx.sessionId : undefined,
1492
- model,
1246
+ model: currentModel,
1493
1247
  prompt_pack_versions: Object.keys(promptPackVersions).length > 0 ? promptPackVersions : undefined,
1494
1248
  memory_summary: sessionCtx.state.summary,
1495
1249
  memory_facts: sessionCtx.state.facts,