@dotsetlabs/dotclaw 1.5.2 → 1.6.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 (82) hide show
  1. package/README.md +6 -3
  2. package/config-examples/runtime.json +9 -8
  3. package/config-examples/tool-policy.json +6 -0
  4. package/container/agent-runner/package-lock.json +2 -2
  5. package/container/agent-runner/package.json +1 -1
  6. package/container/agent-runner/src/agent-config.ts +6 -13
  7. package/container/agent-runner/src/container-protocol.ts +0 -6
  8. package/container/agent-runner/src/id.ts +4 -0
  9. package/container/agent-runner/src/index.ts +175 -178
  10. package/container/agent-runner/src/ipc.ts +3 -15
  11. package/container/agent-runner/src/prompt-packs.ts +5 -209
  12. package/container/agent-runner/src/tools.ts +6 -5
  13. package/dist/agent-execution.d.ts +0 -6
  14. package/dist/agent-execution.d.ts.map +1 -1
  15. package/dist/agent-execution.js +2 -2
  16. package/dist/agent-execution.js.map +1 -1
  17. package/dist/background-jobs.d.ts +1 -0
  18. package/dist/background-jobs.d.ts.map +1 -1
  19. package/dist/background-jobs.js +18 -3
  20. package/dist/background-jobs.js.map +1 -1
  21. package/dist/behavior-config.d.ts +0 -1
  22. package/dist/behavior-config.d.ts.map +1 -1
  23. package/dist/behavior-config.js +0 -3
  24. package/dist/behavior-config.js.map +1 -1
  25. package/dist/cli.js +294 -41
  26. package/dist/cli.js.map +1 -1
  27. package/dist/config.d.ts +1 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +1 -0
  30. package/dist/config.js.map +1 -1
  31. package/dist/container-protocol.d.ts +0 -6
  32. package/dist/container-protocol.d.ts.map +1 -1
  33. package/dist/container-runner.d.ts +5 -0
  34. package/dist/container-runner.d.ts.map +1 -1
  35. package/dist/container-runner.js +44 -2
  36. package/dist/container-runner.js.map +1 -1
  37. package/dist/dashboard.js +1 -1
  38. package/dist/dashboard.js.map +1 -1
  39. package/dist/db.d.ts +19 -1
  40. package/dist/db.d.ts.map +1 -1
  41. package/dist/db.js +130 -28
  42. package/dist/db.js.map +1 -1
  43. package/dist/id.d.ts +2 -0
  44. package/dist/id.d.ts.map +1 -0
  45. package/dist/id.js +4 -0
  46. package/dist/id.js.map +1 -0
  47. package/dist/index.js +152 -272
  48. package/dist/index.js.map +1 -1
  49. package/dist/json-helpers.d.ts +1 -0
  50. package/dist/json-helpers.d.ts.map +1 -1
  51. package/dist/json-helpers.js +33 -1
  52. package/dist/json-helpers.js.map +1 -1
  53. package/dist/maintenance.d.ts +1 -0
  54. package/dist/maintenance.d.ts.map +1 -1
  55. package/dist/maintenance.js +13 -3
  56. package/dist/maintenance.js.map +1 -1
  57. package/dist/memory-embeddings.d.ts +1 -0
  58. package/dist/memory-embeddings.d.ts.map +1 -1
  59. package/dist/memory-embeddings.js +10 -1
  60. package/dist/memory-embeddings.js.map +1 -1
  61. package/dist/memory-store.d.ts.map +1 -1
  62. package/dist/memory-store.js +2 -1
  63. package/dist/memory-store.js.map +1 -1
  64. package/dist/metrics.d.ts +1 -0
  65. package/dist/metrics.d.ts.map +1 -1
  66. package/dist/metrics.js +16 -2
  67. package/dist/metrics.js.map +1 -1
  68. package/dist/paths.d.ts +4 -2
  69. package/dist/paths.d.ts.map +1 -1
  70. package/dist/paths.js +4 -2
  71. package/dist/paths.js.map +1 -1
  72. package/dist/runtime-config.d.ts +4 -7
  73. package/dist/runtime-config.d.ts.map +1 -1
  74. package/dist/runtime-config.js +13 -16
  75. package/dist/runtime-config.js.map +1 -1
  76. package/dist/task-scheduler.d.ts +1 -0
  77. package/dist/task-scheduler.d.ts.map +1 -1
  78. package/dist/task-scheduler.js +10 -1
  79. package/dist/task-scheduler.js.map +1 -1
  80. package/dist/types.d.ts +14 -0
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +6 -1
@@ -26,7 +26,7 @@ import {
26
26
  MemoryConfig,
27
27
  Message
28
28
  } from './memory.js';
29
- import { loadPromptPackWithCanary, formatTaskExtractionPack, formatResponseQualityPack, formatToolCallingPack, formatToolOutcomePack, formatMemoryPolicyPack, formatMemoryRecallPack, PromptPack } from './prompt-packs.js';
29
+ import { loadPromptPackWithCanary, formatPromptPack, PromptPack } from './prompt-packs.js';
30
30
 
31
31
  type OpenRouterResult = ReturnType<OpenRouter['callModel']>;
32
32
 
@@ -71,72 +71,64 @@ function log(message: string): void {
71
71
  console.error(`[agent-runner] ${message}`);
72
72
  }
73
73
 
74
- function coerceTextFromContent(content: unknown): string {
75
- if (!content) return '';
76
- if (typeof content === 'string') return content;
77
- if (Array.isArray(content)) {
78
- return content.map(part => {
79
- if (!part) return '';
80
- if (typeof part === 'string') return part;
81
- if (typeof part === 'object') {
82
- const record = part as { text?: unknown; content?: unknown; value?: unknown };
83
- if (typeof record.text === 'string') return record.text;
84
- if (typeof record.content === 'string') return record.content;
85
- if (typeof record.value === 'string') return record.value;
86
- }
87
- return '';
88
- }).join('');
89
- }
90
- if (typeof content === 'object') {
91
- const record = content as { text?: unknown; content?: unknown; value?: unknown };
92
- if (typeof record.text === 'string') return record.text;
93
- if (typeof record.content === 'string') return record.content;
94
- if (typeof record.value === 'string') return record.value;
95
- }
96
- return '';
74
+ // ── Response extraction pipeline ─────────────────────────────────────
75
+ // OpenRouter SDK v0.3.x returns raw response IDs (gen-*, resp-*, etc.) instead
76
+ // of text for fast reasoning models (GPT-5-mini/nano). Reasoning tokens consume
77
+ // the output budget, leaving nothing for actual text. This multi-layer pipeline
78
+ // works around that:
79
+ // 1. isLikelyResponseId detect leaked IDs so we never surface them
80
+ // 2. extractTextFromRawResponse walk raw response fields ourselves
81
+ // 3. getTextWithFallback try SDK getText(), fall back to raw extraction
82
+ // 4. chatCompletionsFallback retry via /chat/completions when all else fails
83
+ // Remove this pipeline once the SDK reliably returns text for reasoning models.
84
+
85
+ const RESPONSE_ID_PREFIXES = ['gen-', 'resp-', 'resp_', 'chatcmpl-', 'msg_'];
86
+
87
+ function isLikelyResponseId(value: string): boolean {
88
+ const trimmed = value.trim();
89
+ if (!trimmed || trimmed.includes(' ') || trimmed.includes('\n')) return false;
90
+ return RESPONSE_ID_PREFIXES.some(prefix => trimmed.startsWith(prefix));
97
91
  }
98
92
 
99
- function extractTextFallbackFromResponse(response: unknown): string {
93
+ function isValidText(value: unknown): value is string {
94
+ return typeof value === 'string' && value.trim().length > 0 && !isLikelyResponseId(value);
95
+ }
96
+
97
+ function extractTextFromRawResponse(response: unknown): string {
100
98
  if (!response || typeof response !== 'object') return '';
101
- const record = response as {
102
- outputText?: unknown;
103
- output_text?: unknown;
104
- output?: unknown;
105
- choices?: unknown;
106
- };
99
+ const record = response as Record<string, unknown>;
107
100
 
108
- if (typeof record.outputText === 'string' && record.outputText.trim()) {
109
- return record.outputText;
110
- }
111
- if (typeof record.output_text === 'string' && record.output_text.trim()) {
112
- return record.output_text;
113
- }
101
+ // 1. SDK-parsed camelCase field
102
+ if (isValidText(record.outputText)) return record.outputText;
114
103
 
104
+ // 2. Raw API snake_case field
105
+ if (isValidText(record.output_text)) return record.output_text;
106
+
107
+ // 3. Walk response.output[] for message/output_text items
115
108
  if (Array.isArray(record.output)) {
116
- const outputTexts: string[] = [];
109
+ const parts: string[] = [];
117
110
  for (const item of record.output) {
118
111
  if (!item || typeof item !== 'object') continue;
119
- const typed = item as { type?: unknown; content?: unknown; text?: unknown };
120
- const type = typeof typed.type === 'string' ? typed.type : '';
121
- if (type === 'message') {
122
- const text = coerceTextFromContent(typed.content);
123
- if (text.trim()) outputTexts.push(text);
124
- } else if (type === 'output_text' && typeof typed.text === 'string' && typed.text.trim()) {
125
- outputTexts.push(typed.text);
112
+ const typed = item as { type?: string; content?: unknown; text?: string };
113
+ if (typed.type === 'message' && Array.isArray(typed.content)) {
114
+ for (const part of typed.content as Array<{ type?: string; text?: string }>) {
115
+ if (part?.type === 'output_text' && isValidText(part.text)) {
116
+ parts.push(part.text);
117
+ }
118
+ }
119
+ } else if (typed.type === 'output_text' && isValidText(typed.text)) {
120
+ parts.push(typed.text);
126
121
  }
127
122
  }
128
- const joined = outputTexts.join('');
123
+ const joined = parts.join('');
129
124
  if (joined.trim()) return joined;
130
125
  }
131
126
 
127
+ // 4. OpenAI chat completions compat
132
128
  if (Array.isArray(record.choices) && record.choices.length > 0) {
133
- const choice = record.choices[0] as { message?: { content?: unknown }; text?: unknown } | null | undefined;
134
- if (choice?.message) {
135
- const text = coerceTextFromContent(choice.message.content);
136
- if (text.trim()) return text;
137
- }
138
- if (typeof choice?.text === 'string' && choice.text.trim()) {
139
- return choice.text;
129
+ const choice = record.choices[0] as { message?: { content?: unknown } } | null | undefined;
130
+ if (choice?.message && isValidText(choice.message.content)) {
131
+ return choice.message.content;
140
132
  }
141
133
  }
142
134
 
@@ -144,22 +136,103 @@ function extractTextFallbackFromResponse(response: unknown): string {
144
136
  }
145
137
 
146
138
  async function getTextWithFallback(result: OpenRouterResult, context: string): Promise<string> {
147
- const text = await result.getText();
148
- if (text && text.trim()) {
149
- return text;
139
+ // 1. Try the SDK's proper getText() first — this handles tool execution and
140
+ // extracts text from the final response via the SDK's own logic.
141
+ try {
142
+ const text = await result.getText();
143
+ if (isValidText(text)) {
144
+ return text;
145
+ }
146
+ if (text && isLikelyResponseId(text)) {
147
+ log(`Ignored response id from getText (${context}): ${String(text).slice(0, 60)}`);
148
+ }
149
+ } catch (err) {
150
+ log(`getText failed (${context}): ${err instanceof Error ? err.message : String(err)}`);
150
151
  }
152
+
153
+ // 2. Fall back to raw response extraction — walk known fields ourselves
151
154
  try {
152
155
  const response = await result.getResponse();
153
- const fallbackText = extractTextFallbackFromResponse(response);
154
- if (fallbackText && fallbackText.trim()) {
155
- log(`Recovered empty response text from payload (${context})`);
156
+ const fallbackText = extractTextFromRawResponse(response);
157
+ if (fallbackText) {
158
+ log(`Recovered text from raw response (${context})`);
156
159
  return fallbackText;
157
160
  }
158
- log(`Model returned empty response and fallback extraction failed (${context})`);
161
+ const r = response as Record<string, unknown>;
162
+ const outputLen = Array.isArray(r.output) ? (r.output as unknown[]).length : 0;
163
+ log(`No text in raw response (${context}): id=${String(r.id ?? 'none').slice(0, 40)} status=${String(r.status ?? '?')} outputs=${outputLen}`);
159
164
  } catch (err) {
160
- log(`Failed to recover empty response text (${context}): ${err instanceof Error ? err.message : String(err)}`);
165
+ log(`Raw response extraction failed (${context}): ${err instanceof Error ? err.message : String(err)}`);
161
166
  }
162
- return text;
167
+
168
+ // 3. Never return a response ID
169
+ return '';
170
+ }
171
+
172
+ /**
173
+ * Direct Chat Completions API fallback.
174
+ * When the Responses API returns a gen-ID instead of text (common with fast
175
+ * models like gpt-5-nano/mini via OpenRouter), retry using the standard
176
+ * /chat/completions endpoint which reliably returns text content.
177
+ */
178
+ async function chatCompletionsFallback(params: {
179
+ model: string;
180
+ instructions: string;
181
+ messages: Array<{ role: string; content: string }>;
182
+ maxOutputTokens: number;
183
+ temperature: number;
184
+ }): Promise<string> {
185
+ const apiKey = process.env.OPENROUTER_API_KEY;
186
+ if (!apiKey) return '';
187
+
188
+ const headers: Record<string, string> = {
189
+ 'Authorization': `Bearer ${apiKey}`,
190
+ 'Content-Type': 'application/json'
191
+ };
192
+ if (agent.openrouter.siteUrl) {
193
+ headers['HTTP-Referer'] = agent.openrouter.siteUrl;
194
+ }
195
+ if (agent.openrouter.siteName) {
196
+ headers['X-Title'] = agent.openrouter.siteName;
197
+ }
198
+
199
+ const chatMessages = [
200
+ { role: 'system', content: params.instructions },
201
+ ...params.messages
202
+ ];
203
+
204
+ log(`Chat Completions fallback: model=${params.model}, messages=${chatMessages.length}`);
205
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
206
+ method: 'POST',
207
+ headers,
208
+ body: JSON.stringify({
209
+ model: params.model,
210
+ messages: chatMessages,
211
+ max_completion_tokens: params.maxOutputTokens,
212
+ temperature: params.temperature,
213
+ reasoning_effort: 'low'
214
+ }),
215
+ signal: AbortSignal.timeout(agent.openrouter.timeoutMs)
216
+ });
217
+
218
+ const bodyText = await response.text();
219
+ if (!response.ok) {
220
+ log(`Chat Completions fallback HTTP ${response.status}: ${bodyText.slice(0, 300)}`);
221
+ return '';
222
+ }
223
+
224
+ try {
225
+ const data = JSON.parse(bodyText);
226
+ const content = data?.choices?.[0]?.message?.content;
227
+ if (isValidText(content)) {
228
+ log(`Chat Completions fallback recovered text (${String(content).length} chars)`);
229
+ return content;
230
+ }
231
+ log(`Chat Completions fallback returned no text: ${JSON.stringify(data).slice(0, 300)}`);
232
+ } catch (err) {
233
+ log(`Chat Completions fallback parse error: ${err instanceof Error ? err.message : String(err)}`);
234
+ }
235
+ return '';
163
236
  }
164
237
 
165
238
  function writeOutput(output: ContainerOutput): void {
@@ -536,53 +609,15 @@ function buildSystemInstructions(params: {
536
609
  ? `Job artifacts directory: /workspace/group/jobs/${params.jobId}`
537
610
  : '';
538
611
 
539
- const taskExtractionBlock = params.taskExtractionPack
540
- ? formatTaskExtractionPack({
541
- pack: params.taskExtractionPack,
542
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
543
- maxChars: PROMPT_PACKS_MAX_CHARS
544
- })
545
- : '';
546
-
547
- const responseQualityBlock = params.responseQualityPack
548
- ? formatResponseQualityPack({
549
- pack: params.responseQualityPack,
550
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
551
- maxChars: PROMPT_PACKS_MAX_CHARS
552
- })
553
- : '';
554
-
555
- const toolCallingBlock = params.toolCallingPack
556
- ? formatToolCallingPack({
557
- pack: params.toolCallingPack,
558
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
559
- maxChars: PROMPT_PACKS_MAX_CHARS
560
- })
561
- : '';
562
-
563
- const toolOutcomeBlock = params.toolOutcomePack
564
- ? formatToolOutcomePack({
565
- pack: params.toolOutcomePack,
566
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
567
- maxChars: PROMPT_PACKS_MAX_CHARS
568
- })
569
- : '';
570
-
571
- const memoryPolicyBlock = params.memoryPolicyPack
572
- ? formatMemoryPolicyPack({
573
- pack: params.memoryPolicyPack,
574
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
575
- maxChars: PROMPT_PACKS_MAX_CHARS
576
- })
577
- : '';
612
+ const fmtPack = (label: string, pack: PromptPack | null | undefined) =>
613
+ pack ? formatPromptPack({ label, pack, maxDemos: PROMPT_PACKS_MAX_DEMOS, maxChars: PROMPT_PACKS_MAX_CHARS }) : '';
578
614
 
579
- const memoryRecallBlock = params.memoryRecallPack
580
- ? formatMemoryRecallPack({
581
- pack: params.memoryRecallPack,
582
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
583
- maxChars: PROMPT_PACKS_MAX_CHARS
584
- })
585
- : '';
615
+ const taskExtractionBlock = fmtPack('Task Extraction Guidelines', params.taskExtractionPack);
616
+ const responseQualityBlock = fmtPack('Response Quality Guidelines', params.responseQualityPack);
617
+ const toolCallingBlock = fmtPack('Tool Calling Guidelines', params.toolCallingPack);
618
+ const toolOutcomeBlock = fmtPack('Tool Outcome Guidelines', params.toolOutcomePack);
619
+ const memoryPolicyBlock = fmtPack('Memory Policy Guidelines', params.memoryPolicyPack);
620
+ const memoryRecallBlock = fmtPack('Memory Recall Guidelines', params.memoryRecallPack);
586
621
 
587
622
  return [
588
623
  `You are ${params.assistantName}, a personal assistant running inside DotClaw.`,
@@ -712,7 +747,8 @@ async function updateMemorySummary(params: {
712
747
  instructions: prompt.instructions,
713
748
  input: prompt.input,
714
749
  maxOutputTokens: params.maxOutputTokens,
715
- temperature: 0.1
750
+ temperature: 0.1,
751
+ reasoning: { effort: 'low' as const }
716
752
  });
717
753
  const text = await getTextWithFallback(result, 'summary');
718
754
  return parseSummaryResponse(text);
@@ -726,7 +762,8 @@ function buildMemoryExtractionPrompt(params: {
726
762
  memoryPolicyPack?: PromptPack | null;
727
763
  }): { instructions: string; input: string } {
728
764
  const policyBlock = params.memoryPolicyPack
729
- ? formatMemoryPolicyPack({
765
+ ? formatPromptPack({
766
+ label: 'Memory Policy Guidelines',
730
767
  pack: params.memoryPolicyPack,
731
768
  maxDemos: PROMPT_PACKS_MAX_DEMOS,
732
769
  maxChars: PROMPT_PACKS_MAX_CHARS
@@ -853,7 +890,8 @@ async function validateResponseQuality(params: {
853
890
  instructions: prompt.instructions,
854
891
  input: prompt.input,
855
892
  maxOutputTokens: params.maxOutputTokens,
856
- temperature: params.temperature
893
+ temperature: params.temperature,
894
+ reasoning: { effort: 'low' as const }
857
895
  });
858
896
  const text = await getTextWithFallback(result, 'response_validation');
859
897
  return parseResponseValidation(text);
@@ -959,24 +997,6 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
959
997
  const responseValidateMinPromptTokens = agent.responseValidation.minPromptTokens || 0;
960
998
  const responseValidateMinResponseTokens = agent.responseValidation.minResponseTokens || 0;
961
999
  const maxContextMessageTokens = agent.context.maxContextMessageTokens;
962
- const streamingEnabled = Boolean(input.streaming?.enabled && typeof input.streaming?.draftId === 'number');
963
- const streamingDraftId = streamingEnabled ? input.streaming?.draftId : undefined;
964
- const streamingMinIntervalMs = Math.max(
965
- 0,
966
- Math.floor(
967
- typeof input.streaming?.minIntervalMs === 'number'
968
- ? input.streaming.minIntervalMs
969
- : agent.streaming.minIntervalMs
970
- )
971
- );
972
- const streamingMinChars = Math.max(
973
- 1,
974
- Math.floor(
975
- typeof input.streaming?.minChars === 'number'
976
- ? input.streaming.minChars
977
- : agent.streaming.minChars
978
- )
979
- );
980
1000
 
981
1001
  const openrouter = getCachedOpenRouter(apiKey, openrouterOptions);
982
1002
  const tokenEstimate = resolveTokenEstimate(input, agentConfig);
@@ -1008,21 +1028,6 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1008
1028
  }
1009
1029
  });
1010
1030
 
1011
- let streamLastSentAt = 0;
1012
- let streamLastSentLength = 0;
1013
- const sendStreamUpdate = (text: string, force = false) => {
1014
- if (!streamingEnabled || !streamingDraftId) return;
1015
- if (!text || !text.trim()) return;
1016
- const now = Date.now();
1017
- if (!force) {
1018
- if (now - streamLastSentAt < streamingMinIntervalMs) return;
1019
- if (text.length - streamLastSentLength < streamingMinChars) return;
1020
- }
1021
- streamLastSentAt = now;
1022
- streamLastSentLength = text.length;
1023
- void ipc.sendDraft(text, streamingDraftId).catch(() => undefined);
1024
- };
1025
-
1026
1031
  if (process.env.DOTCLAW_SELF_CHECK === '1') {
1027
1032
  try {
1028
1033
  const details = await runSelfCheck({ model });
@@ -1225,7 +1230,8 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1225
1230
  instructions: plannerPrompt.instructions,
1226
1231
  input: plannerPrompt.input,
1227
1232
  maxOutputTokens: plannerMaxOutputTokens,
1228
- temperature: plannerTemperature
1233
+ temperature: plannerTemperature,
1234
+ reasoning: { effort: 'low' as const }
1229
1235
  });
1230
1236
  const plannerText = await getTextWithFallback(plannerResult, 'planner');
1231
1237
  const plan = parsePlannerResponse(plannerText);
@@ -1292,47 +1298,39 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1292
1298
  stopWhen: stepCountIs(maxToolSteps),
1293
1299
  maxOutputTokens: config.maxOutputTokens,
1294
1300
  temperature: config.temperature,
1295
- stream: streamingEnabled
1301
+ reasoning: { effort: 'low' as const }
1296
1302
  };
1297
- const result = await openrouter.callModel(callParams as Parameters<typeof openrouter.callModel>[0]);
1303
+ const result = await openrouter.callModel(callParams);
1298
1304
  const localLatencyMs = Date.now() - startedAt;
1305
+
1306
+ // Get the complete response text via the SDK's proper getText() path
1307
+ let localResponseText = await getTextWithFallback(result, 'completion');
1308
+
1299
1309
  const toolCallsFromModel = await result.getToolCalls();
1300
1310
  if (toolCallsFromModel.length > 0) {
1301
1311
  log(`Model made ${toolCallsFromModel.length} tool call(s): ${toolCallsFromModel.map(t => t.name).join(', ')}`);
1302
1312
  }
1303
1313
 
1304
- let localResponseText = '';
1305
- let streamed = false;
1306
- if (streamingEnabled && typeof (result as { getTextStream?: () => AsyncIterable<unknown> }).getTextStream === 'function') {
1307
- try {
1308
- const stream = (result as { getTextStream: () => AsyncIterable<unknown> }).getTextStream();
1309
- for await (const chunk of stream) {
1310
- const delta = typeof chunk === 'string'
1311
- ? chunk
1312
- : (typeof (chunk as { text?: unknown })?.text === 'string' ? (chunk as { text?: string }).text || '' : '');
1313
- if (!delta) continue;
1314
- localResponseText += delta;
1315
- sendStreamUpdate(localResponseText);
1316
- }
1317
- if (localResponseText) {
1318
- sendStreamUpdate(localResponseText, true);
1319
- }
1320
- streamed = true;
1321
- } catch (err) {
1322
- log(`Streaming failed, falling back to full response: ${err instanceof Error ? err.message : String(err)}`);
1323
- }
1324
- }
1325
- if (!streamed || !localResponseText || !localResponseText.trim()) {
1326
- localResponseText = await getTextWithFallback(result, 'completion');
1327
- if (localResponseText && localResponseText.trim()) {
1328
- sendStreamUpdate(localResponseText, true);
1329
- }
1330
- }
1331
1314
  if (!localResponseText || !localResponseText.trim()) {
1332
1315
  if (toolCallsFromModel.length > 0) {
1333
1316
  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".';
1317
+ } else {
1318
+ // Responses API likely returned a gen-ID; retry with Chat Completions API
1319
+ try {
1320
+ localResponseText = await chatCompletionsFallback({
1321
+ model,
1322
+ instructions: resolvedInstructions,
1323
+ messages: messagesToOpenRouter(contextMessages),
1324
+ maxOutputTokens: config.maxOutputTokens,
1325
+ temperature: config.temperature
1326
+ });
1327
+ } catch (err) {
1328
+ log(`Chat Completions fallback error: ${err instanceof Error ? err.message : String(err)}`);
1329
+ }
1330
+ }
1331
+ if (!localResponseText || !localResponseText.trim()) {
1332
+ log(`Warning: Model returned empty/whitespace response after all fallbacks. tool calls: ${toolCallsFromModel.length}`);
1334
1333
  }
1335
- log(`Warning: Model returned empty/whitespace response. Raw length: ${localResponseText?.length ?? 0}, tool calls: ${toolCallsFromModel.length}`);
1336
1334
  } else {
1337
1335
  log(`Model returned text response (${localResponseText.length} chars)`);
1338
1336
  }
@@ -1392,8 +1390,6 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1392
1390
  }
1393
1391
  retriesLeft -= 1;
1394
1392
  log(`Response validation failed; retrying (${retriesLeft} retries left)`);
1395
- streamLastSentAt = 0;
1396
- streamLastSentLength = 0;
1397
1393
  const retryGuidance = buildRetryGuidance(validationResult);
1398
1394
  const retryAttempt = await runCompletion(retryGuidance);
1399
1395
  responseText = retryAttempt.responseText;
@@ -1464,7 +1460,8 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1464
1460
  instructions: extractionPrompt.instructions,
1465
1461
  input: extractionPrompt.input,
1466
1462
  maxOutputTokens: memoryExtractionMaxOutputTokens,
1467
- temperature: 0.1
1463
+ temperature: 0.1,
1464
+ reasoning: { effort: 'low' as const }
1468
1465
  });
1469
1466
  const extractionText = await getTextWithFallback(extractionResult, 'memory_extraction');
1470
1467
  const extractedItems = parseMemoryExtraction(extractionText);
@@ -6,6 +6,7 @@
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { CronExpressionParser } from 'cron-parser';
9
+ import { generateId } from './id.js';
9
10
 
10
11
  const IPC_DIR = '/workspace/ipc';
11
12
  const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
@@ -27,7 +28,7 @@ export interface IpcConfig {
27
28
  function writeIpcFile(dir: string, data: object): string {
28
29
  fs.mkdirSync(dir, { recursive: true });
29
30
 
30
- const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
31
+ const filename = `${generateId('')}.json`;
31
32
  const filepath = path.join(dir, filename);
32
33
 
33
34
  const tempPath = `${filepath}.tmp`;
@@ -50,7 +51,7 @@ async function requestResponse(
50
51
  fs.mkdirSync(REQUESTS_DIR, { recursive: true });
51
52
  fs.mkdirSync(RESPONSES_DIR, { recursive: true });
52
53
 
53
- const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
54
+ const id = generateId('req');
54
55
  writeIpcFile(REQUESTS_DIR, {
55
56
  id,
56
57
  type,
@@ -92,19 +93,6 @@ export function createIpcHandlers(ctx: IpcContext, config: IpcConfig) {
92
93
  const filename = writeIpcFile(MESSAGES_DIR, data);
93
94
  return { ok: true, id: filename };
94
95
  },
95
- async sendDraft(text: string, draftId: number) {
96
- const data = {
97
- type: 'message_draft',
98
- chatJid,
99
- text,
100
- draftId,
101
- groupFolder,
102
- timestamp: new Date().toISOString()
103
- };
104
- const filename = writeIpcFile(MESSAGES_DIR, data);
105
- return { ok: true, id: filename };
106
- },
107
-
108
96
  async scheduleTask(args: {
109
97
  prompt: string;
110
98
  schedule_type: 'cron' | 'interval' | 'once';