@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.
- package/README.md +2 -4
- package/config-examples/runtime.json +16 -122
- package/config-examples/tool-policy.json +2 -15
- package/container/agent-runner/src/agent-config.ts +9 -43
- package/container/agent-runner/src/container-protocol.ts +3 -10
- package/container/agent-runner/src/index.ts +207 -453
- package/container/agent-runner/src/ipc.ts +0 -62
- package/container/agent-runner/src/tools.ts +2 -260
- package/dist/agent-execution.d.ts +4 -10
- package/dist/agent-execution.d.ts.map +1 -1
- package/dist/agent-execution.js +14 -22
- package/dist/agent-execution.js.map +1 -1
- package/dist/config.d.ts +1 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -2
- package/dist/config.js.map +1 -1
- package/dist/container-protocol.d.ts +3 -10
- package/dist/container-protocol.d.ts.map +1 -1
- package/dist/dashboard.d.ts +0 -6
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +1 -55
- package/dist/dashboard.js.map +1 -1
- package/dist/db.d.ts +1 -46
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +0 -192
- package/dist/db.js.map +1 -1
- package/dist/error-messages.d.ts.map +1 -1
- package/dist/error-messages.js +5 -1
- package/dist/error-messages.js.map +1 -1
- package/dist/index.js +6 -52
- package/dist/index.js.map +1 -1
- package/dist/ipc-dispatcher.d.ts.map +1 -1
- package/dist/ipc-dispatcher.js +0 -183
- package/dist/ipc-dispatcher.js.map +1 -1
- package/dist/maintenance.d.ts.map +1 -1
- package/dist/maintenance.js +3 -13
- package/dist/maintenance.js.map +1 -1
- package/dist/message-pipeline.d.ts +1 -1
- package/dist/message-pipeline.d.ts.map +1 -1
- package/dist/message-pipeline.js +146 -370
- package/dist/message-pipeline.js.map +1 -1
- package/dist/metrics.d.ts +0 -3
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +2 -33
- package/dist/metrics.js.map +1 -1
- package/dist/providers/discord/discord-provider.d.ts.map +1 -1
- package/dist/providers/discord/discord-provider.js +6 -3
- package/dist/providers/discord/discord-provider.js.map +1 -1
- package/dist/request-router.d.ts +9 -31
- package/dist/request-router.d.ts.map +1 -1
- package/dist/request-router.js +12 -128
- package/dist/request-router.js.map +1 -1
- package/dist/runtime-config.d.ts +15 -101
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +37 -159
- package/dist/runtime-config.js.map +1 -1
- package/dist/streaming.d.ts +58 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +196 -0
- package/dist/streaming.js.map +1 -0
- package/dist/task-scheduler.d.ts.map +1 -1
- package/dist/task-scheduler.js +9 -47
- package/dist/task-scheduler.js.map +1 -1
- package/dist/tool-policy.d.ts.map +1 -1
- package/dist/tool-policy.js +0 -10
- package/dist/tool-policy.js.map +1 -1
- package/dist/types.d.ts +0 -41
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -2
- package/scripts/configure.js +81 -0
- package/scripts/install.sh +1 -1
- package/config-examples/plugin-http.json +0 -18
- package/container/skills/agent-browser.md +0 -159
- package/dist/background-job-classifier.d.ts +0 -20
- package/dist/background-job-classifier.d.ts.map +0 -1
- package/dist/background-job-classifier.js +0 -145
- package/dist/background-job-classifier.js.map +0 -1
- package/dist/background-jobs.d.ts +0 -56
- package/dist/background-jobs.d.ts.map +0 -1
- package/dist/background-jobs.js +0 -559
- package/dist/background-jobs.js.map +0 -1
- package/dist/orchestration.d.ts +0 -39
- package/dist/orchestration.d.ts.map +0 -1
- package/dist/orchestration.js +0 -136
- package/dist/orchestration.js.map +0 -1
- package/dist/planner-probe.d.ts +0 -14
- package/dist/planner-probe.d.ts.map +0 -1
- package/dist/planner-probe.js +0 -97
- package/dist/planner-probe.js.map +0 -1
- package/dist/tool-intent-probe.d.ts +0 -11
- package/dist/tool-intent-probe.d.ts.map +0 -1
- package/dist/tool-intent-probe.js +0 -63
- package/dist/tool-intent-probe.js.map +0 -1
- package/dist/workflow-engine.d.ts +0 -51
- package/dist/workflow-engine.d.ts.map +0 -1
- package/dist/workflow-engine.js +0 -281
- package/dist/workflow-engine.js.map +0 -1
- package/dist/workflow-store.d.ts +0 -39
- package/dist/workflow-store.d.ts.map +0 -1
- package/dist/workflow-store.js +0 -173
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(/&/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 =
|
|
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
|
|
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: {
|
|
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 = (
|
|
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
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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,
|