@dotsetlabs/dotclaw 2.6.0 → 2.6.2

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.
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "memory": {
27
27
  "recall": {
28
- "minScore": 0.35
28
+ "minScore": 0.35,
29
+ "timeoutMs": 15000
29
30
  },
30
31
  "embeddings": {
31
32
  "enabled": true
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "dotclaw-agent-runner",
9
- "version": "2.3.0",
9
+ "version": "2.3.1",
10
10
  "dependencies": {
11
11
  "@openrouter/sdk": "^0.3.0",
12
12
  "cron-parser": "^5.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "description": "Container-side agent runner for DotClaw",
6
6
  "main": "dist/index.js",
@@ -6,7 +6,7 @@
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { fileURLToPath } from 'url';
9
- import { OpenRouter } from '@openrouter/sdk';
9
+ import { OpenRouter, type ResolvedCallModelInput } from '@openrouter/sdk';
10
10
  import { createTools, discoverMcpTools, ToolCallRecord, type ToolResultRecord } from './tools.js';
11
11
  import { createIpcHandlers } from './ipc.js';
12
12
  import { loadAgentConfig } from './agent-config.js';
@@ -53,9 +53,15 @@ import {
53
53
  injectImagesIntoContextInput,
54
54
  loadImageAttachmentsForInput,
55
55
  messagesToOpenRouterInput,
56
+ sanitizeConversationInputForResponses,
56
57
  } from './openrouter-input.js';
58
+ import {
59
+ extractFunctionCallsForReplay,
60
+ toReplayFunctionCallItems,
61
+ } from './openrouter-followup.js';
57
62
 
58
63
  type OpenRouterResult = ReturnType<OpenRouter['callModel']>;
64
+ type OpenRouterResolvedInputArray = Extract<NonNullable<ResolvedCallModelInput['input']>, unknown[]>;
59
65
 
60
66
 
61
67
  const SESSION_ROOT = '/workspace/session';
@@ -208,21 +214,6 @@ function extractTextFromApiResponse(response: any): string {
208
214
  return '';
209
215
  }
210
216
 
211
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- function extractFunctionCalls(response: any): Array<{ id: string; name: string; arguments: any }> {
213
- const calls: Array<{ id: string; name: string; arguments: unknown }> = [];
214
- for (const item of response?.output || []) {
215
- if (item?.type === 'function_call') {
216
- let args = item.arguments;
217
- if (typeof args === 'string') {
218
- try { args = JSON.parse(args); } catch { /* keep as string */ }
219
- }
220
- calls.push({ id: item.callId, name: item.name, arguments: args });
221
- }
222
- }
223
- return calls;
224
- }
225
-
226
217
  function writeOutput(output: ContainerOutput): void {
227
218
  console.log(OUTPUT_START_MARKER);
228
219
  console.log(JSON.stringify(output));
@@ -1223,6 +1214,21 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1223
1214
  }
1224
1215
  };
1225
1216
 
1217
+ const sanitizeConversationInput = (
1218
+ items: unknown[],
1219
+ label: string
1220
+ ): OpenRouterResolvedInputArray => {
1221
+ const sanitized = sanitizeConversationInputForResponses(items);
1222
+ if (sanitized.rewrittenCount > 0 || sanitized.droppedCount > 0) {
1223
+ log(`Sanitized OpenRouter input (${label}): rewritten=${sanitized.rewrittenCount}, dropped=${sanitized.droppedCount}`);
1224
+ }
1225
+ if (sanitized.items.length === 0) {
1226
+ log(`Sanitized OpenRouter input (${label}) produced empty payload; inserting fallback message`);
1227
+ return [{ role: 'user', content: '[No usable conversation context available.]' }] as OpenRouterResolvedInputArray;
1228
+ }
1229
+ return sanitized.items as OpenRouterResolvedInputArray;
1230
+ };
1231
+
1226
1232
  try {
1227
1233
  const { instructions: resolvedInstructions, instructionsTokens: resolvedInstructionTokens, contextMessages } = buildContext();
1228
1234
  // Apply 1.3x safety margin to account for token estimation inaccuracy.
@@ -1244,12 +1250,13 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1244
1250
  }
1245
1251
  }
1246
1252
 
1247
- const contextInput = messagesToOpenRouterInput(contextMessages);
1253
+ const rawContextInput = messagesToOpenRouterInput(contextMessages);
1248
1254
 
1249
1255
  // Inject vision content into the last user message if images are present.
1250
1256
  // Uses OpenRouter Responses API content part types (input_text/input_image).
1251
1257
  const imageContent = loadImageAttachmentsForInput(input.attachments, { log });
1252
- injectImagesIntoContextInput(contextInput, imageContent);
1258
+ injectImagesIntoContextInput(rawContextInput, imageContent);
1259
+ const contextInput: OpenRouterResolvedInputArray = sanitizeConversationInput(rawContextInput, 'context');
1253
1260
 
1254
1261
  let lastError: unknown = null;
1255
1262
  for (let attempt = 0; attempt < modelChain.length; attempt++) {
@@ -1272,11 +1279,11 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1272
1279
  // We use schema-only tools (no execute functions) so the SDK returns tool calls
1273
1280
  // without auto-executing, then run the loop ourselves with full context.
1274
1281
 
1275
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1276
- let conversationInput: any[] = [...contextInput];
1282
+ let conversationInput: OpenRouterResolvedInputArray = sanitizeConversationInput([...contextInput], 'initial_conversation');
1277
1283
  let step = 0;
1278
1284
 
1279
1285
  // Initial call — uses streaming for real-time delivery
1286
+ conversationInput = sanitizeConversationInput(conversationInput, `initial_call:${currentModel}`);
1280
1287
  const initialResult = openrouter.callModel({
1281
1288
  model: currentModel,
1282
1289
  instructions: resolvedInstructions,
@@ -1311,7 +1318,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1311
1318
  }
1312
1319
 
1313
1320
  responseText = extractTextFromApiResponse(lastResponse);
1314
- let pendingCalls = extractFunctionCalls(lastResponse);
1321
+ let pendingCalls = extractFunctionCallsForReplay(lastResponse);
1315
1322
  const callSignatureCounts = new Map<string, number>();
1316
1323
  let previousRoundSignature = '';
1317
1324
  let repeatedRoundCount = 0;
@@ -1430,9 +1437,12 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1430
1437
  reason: nudgeReason,
1431
1438
  attempt: toolRequirementNudgeAttempt
1432
1439
  });
1433
- const responseItems = Array.isArray(lastResponse?.output) ? lastResponse.output : [];
1434
- conversationInput = [...conversationInput, ...responseItems, { role: 'user', content: nudgePrompt }];
1440
+ if (responseText) {
1441
+ conversationInput = [...conversationInput, { role: 'assistant', content: responseText }];
1442
+ }
1443
+ conversationInput = [...conversationInput, { role: 'user', content: nudgePrompt }];
1435
1444
  try {
1445
+ conversationInput = sanitizeConversationInput(conversationInput, `tool_nudge:${toolRequirementNudgeAttempt}`);
1436
1446
  const nudgeResult = openrouter.callModel({
1437
1447
  model: currentModel,
1438
1448
  instructions: resolvedInstructions,
@@ -1448,7 +1458,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1448
1458
  responseText = nudgeText;
1449
1459
  writeStreamChunk(nudgeText);
1450
1460
  }
1451
- pendingCalls = extractFunctionCalls(lastResponse);
1461
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1452
1462
  } catch (nudgeErr) {
1453
1463
  log(`Tool requirement nudge failed: ${nudgeErr instanceof Error ? nudgeErr.message : String(nudgeErr)}`);
1454
1464
  break;
@@ -1588,7 +1598,8 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1588
1598
 
1589
1599
  // Build follow-up input with FULL conversation context:
1590
1600
  // original messages + model output + tool results (accumulated each round)
1591
- conversationInput = [...conversationInput, ...lastResponse.output, ...toolResults];
1601
+ const replayFunctionCalls = toReplayFunctionCallItems(pendingCalls);
1602
+ conversationInput = [...conversationInput, ...replayFunctionCalls, ...toolResults];
1592
1603
 
1593
1604
  // Compact oversized tool payloads before follow-up calls to reduce context bloat.
1594
1605
  const compactedConversation = compactToolConversationItems(conversationInput, {
@@ -1643,6 +1654,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1643
1654
  }
1644
1655
 
1645
1656
  // Follow-up call with complete context — model sees the full conversation
1657
+ conversationInput = sanitizeConversationInput(conversationInput, `tool_followup:${step}`);
1646
1658
  const followupResult = openrouter.callModel({
1647
1659
  model: currentModel,
1648
1660
  instructions: resolvedInstructions,
@@ -1680,6 +1692,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1680
1692
  if (cleared > 0) {
1681
1693
  log(`Hard-cleared ${cleared} tool results, retrying`);
1682
1694
  try {
1695
+ conversationInput = sanitizeConversationInput(conversationInput, `tool_followup_retry:${step}`);
1683
1696
  const retryResult = openrouter.callModel({
1684
1697
  model: currentModel,
1685
1698
  instructions: resolvedInstructions,
@@ -1695,7 +1708,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1695
1708
  responseText = retryText;
1696
1709
  writeStreamChunk(retryText);
1697
1710
  }
1698
- pendingCalls = extractFunctionCalls(lastResponse);
1711
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1699
1712
  continue;
1700
1713
  } catch (retryErr) {
1701
1714
  log(`Context overflow retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
@@ -1715,7 +1728,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1715
1728
  writeStreamChunk(followupText);
1716
1729
  }
1717
1730
 
1718
- pendingCalls = extractFunctionCalls(lastResponse);
1731
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1719
1732
  }
1720
1733
 
1721
1734
  if (toolExecutionRequirement.required && toolCalls.length === 0) {
@@ -1745,6 +1758,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1745
1758
  });
1746
1759
  conversationInput = [...conversationInput, { role: 'user', content: continuationPrompt }];
1747
1760
  try {
1761
+ conversationInput = sanitizeConversationInput(conversationInput, `forced_synthesis:${synthesisReason}`);
1748
1762
  const synthesisResult = openrouter.callModel({
1749
1763
  model: currentModel,
1750
1764
  instructions: resolvedInstructions,
@@ -1849,10 +1863,11 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1849
1863
  keepRecentCount: Math.max(1, toKeep.length)
1850
1864
  }).retryInput;
1851
1865
  try {
1866
+ const sanitizedCompactedInput = sanitizeConversationInput(compactedInput, 'context_overflow_retry');
1852
1867
  const retryResult = openrouter.callModel({
1853
1868
  model: currentModel,
1854
1869
  instructions: minInstructions,
1855
- input: compactedInput,
1870
+ input: sanitizedCompactedInput,
1856
1871
  tools: schemaTools,
1857
1872
  maxOutputTokens: effectiveMaxOutputTokens,
1858
1873
  temperature: config.temperature,
@@ -158,21 +158,121 @@ export function appendHistory(ctx: SessionContext, role: 'user' | 'assistant', c
158
158
  return message;
159
159
  }
160
160
 
161
+ function safeStringifyHistoryValue(value: unknown): string {
162
+ try {
163
+ const serialized = JSON.stringify(value);
164
+ return typeof serialized === 'string' ? serialized : String(value);
165
+ } catch {
166
+ return String(value);
167
+ }
168
+ }
169
+
170
+ function coerceHistoryContentToString(value: unknown): string {
171
+ if (typeof value === 'string') return value;
172
+ if (value == null) return '';
173
+
174
+ if (Array.isArray(value)) {
175
+ const parts = value
176
+ .map((part) => {
177
+ if (!part || typeof part !== 'object') return null;
178
+ const record = part as Record<string, unknown>;
179
+ if (typeof record.text === 'string' && record.text.trim()) return record.text;
180
+ if (typeof record.content === 'string' && record.content.trim()) return record.content;
181
+ if (typeof record.output === 'string' && record.output.trim()) return record.output;
182
+ if (typeof record.refusal === 'string' && record.refusal.trim()) return record.refusal;
183
+ return null;
184
+ })
185
+ .filter((part): part is string => typeof part === 'string');
186
+ if (parts.length > 0) return parts.join('\n');
187
+ return safeStringifyHistoryValue(value);
188
+ }
189
+
190
+ if (typeof value === 'object') {
191
+ const record = value as Record<string, unknown>;
192
+ if (typeof record.text === 'string' && record.text.trim()) return record.text;
193
+ if (typeof record.content === 'string' && record.content.trim()) return record.content;
194
+ if (typeof record.output === 'string' && record.output.trim()) return record.output;
195
+ if (typeof record.refusal === 'string' && record.refusal.trim()) return record.refusal;
196
+ return safeStringifyHistoryValue(value);
197
+ }
198
+
199
+ return String(value);
200
+ }
201
+
161
202
  export function loadHistory(ctx: SessionContext): Message[] {
162
203
  if (!fs.existsSync(ctx.historyPath)) return [];
163
- const lines = fs.readFileSync(ctx.historyPath, 'utf-8').trim().split('\n');
204
+ const raw = fs.readFileSync(ctx.historyPath, 'utf-8');
205
+ if (!raw.trim()) return [];
206
+ const lines = raw.trim().split('\n');
164
207
  const messages: Message[] = [];
208
+ let needsRewrite = false;
209
+ let highestSeq = 0;
165
210
  for (const line of lines) {
166
- if (!line.trim()) continue;
211
+ if (!line.trim()) {
212
+ needsRewrite = true;
213
+ continue;
214
+ }
167
215
  try {
168
- const parsed = JSON.parse(line);
169
- if (parsed?.role && parsed?.content) {
170
- messages.push(parsed);
216
+ const parsed = JSON.parse(line) as Record<string, unknown>;
217
+ const role = parsed?.role === 'user' || parsed?.role === 'assistant'
218
+ ? parsed.role
219
+ : null;
220
+ if (!role) {
221
+ needsRewrite = true;
222
+ continue;
223
+ }
224
+
225
+ const content = coerceHistoryContentToString(parsed.content);
226
+ if (typeof parsed.content !== 'string') {
227
+ needsRewrite = true;
228
+ }
229
+
230
+ const timestamp = typeof parsed.timestamp === 'string' && parsed.timestamp
231
+ ? parsed.timestamp
232
+ : new Date().toISOString();
233
+ if (timestamp !== parsed.timestamp) {
234
+ needsRewrite = true;
171
235
  }
236
+
237
+ const parsedSeq = Number(parsed.seq);
238
+ const seq = Number.isFinite(parsedSeq) && parsedSeq > 0
239
+ ? Math.floor(parsedSeq)
240
+ : (highestSeq + 1);
241
+ if (seq !== parsedSeq) {
242
+ needsRewrite = true;
243
+ }
244
+ highestSeq = Math.max(highestSeq, seq);
245
+
246
+ messages.push({
247
+ role,
248
+ content,
249
+ timestamp,
250
+ seq
251
+ });
172
252
  } catch {
173
- // ignore malformed lines
253
+ needsRewrite = true;
174
254
  }
175
255
  }
256
+
257
+ messages.sort((a, b) => a.seq - b.seq);
258
+ let nextSeq = 1;
259
+ for (const message of messages) {
260
+ if (message.seq < nextSeq) {
261
+ message.seq = nextSeq;
262
+ needsRewrite = true;
263
+ }
264
+ nextSeq = message.seq + 1;
265
+ }
266
+
267
+ if (ctx.meta.nextSeq < nextSeq) {
268
+ ctx.meta.nextSeq = nextSeq;
269
+ saveSessionMeta(ctx);
270
+ }
271
+
272
+ if (needsRewrite) {
273
+ writeHistory(ctx, messages);
274
+ }
275
+
176
276
  return messages;
177
277
  }
178
278
 
@@ -0,0 +1,87 @@
1
+ type RawRecord = Record<string, unknown>;
2
+
3
+ export type ExtractedFunctionCall = {
4
+ id: string;
5
+ itemId?: string;
6
+ name: string;
7
+ arguments: unknown;
8
+ argumentsText: string;
9
+ };
10
+
11
+ export type ReplayFunctionCallItem = {
12
+ type: 'function_call';
13
+ id: string;
14
+ callId: string;
15
+ name: string;
16
+ arguments: string;
17
+ };
18
+
19
+ function normalizeCallId(record: RawRecord): { callId?: string; itemId?: string } {
20
+ const callId = typeof record.callId === 'string' && record.callId.trim()
21
+ ? record.callId.trim()
22
+ : (typeof record.id === 'string' && record.id.trim() ? record.id.trim() : undefined);
23
+ const itemId = typeof record.id === 'string' && record.id.trim()
24
+ ? record.id.trim()
25
+ : undefined;
26
+ return { callId, itemId };
27
+ }
28
+
29
+ function normalizeArguments(raw: unknown): { arguments: unknown; argumentsText: string } {
30
+ if (typeof raw === 'string') {
31
+ const trimmed = raw.trim();
32
+ if (!trimmed) return { arguments: {}, argumentsText: '{}' };
33
+ try {
34
+ return { arguments: JSON.parse(trimmed), argumentsText: trimmed };
35
+ } catch {
36
+ return { arguments: raw, argumentsText: raw };
37
+ }
38
+ }
39
+ if (raw == null) return { arguments: {}, argumentsText: '{}' };
40
+ try {
41
+ const serialized = JSON.stringify(raw);
42
+ if (typeof serialized === 'string') {
43
+ return { arguments: raw, argumentsText: serialized };
44
+ }
45
+ } catch {
46
+ // fall through
47
+ }
48
+ return { arguments: raw, argumentsText: String(raw) };
49
+ }
50
+
51
+ function extractFromOutput(output: unknown[]): ExtractedFunctionCall[] {
52
+ const calls: ExtractedFunctionCall[] = [];
53
+ for (const item of output) {
54
+ if (!item || typeof item !== 'object') continue;
55
+ const record = item as RawRecord;
56
+ if (record.type !== 'function_call') continue;
57
+ const { callId, itemId } = normalizeCallId(record);
58
+ const name = typeof record.name === 'string' ? record.name.trim() : '';
59
+ if (!callId || !name) continue;
60
+ const normalizedArguments = normalizeArguments(record.arguments);
61
+ calls.push({
62
+ id: callId,
63
+ itemId,
64
+ name,
65
+ arguments: normalizedArguments.arguments,
66
+ argumentsText: normalizedArguments.argumentsText
67
+ });
68
+ }
69
+ return calls;
70
+ }
71
+
72
+ export function extractFunctionCallsForReplay(response: unknown): ExtractedFunctionCall[] {
73
+ if (!response || typeof response !== 'object') return [];
74
+ const output = (response as { output?: unknown }).output;
75
+ if (!Array.isArray(output)) return [];
76
+ return extractFromOutput(output);
77
+ }
78
+
79
+ export function toReplayFunctionCallItems(calls: ExtractedFunctionCall[]): ReplayFunctionCallItem[] {
80
+ return calls.map((call) => ({
81
+ type: 'function_call',
82
+ id: call.itemId || call.id,
83
+ callId: call.id,
84
+ name: call.name,
85
+ arguments: call.argumentsText || '{}'
86
+ }));
87
+ }