@shuyhere/takotako 0.0.1 → 0.0.21

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 (58) hide show
  1. package/README.md +198 -27
  2. package/dist/auth/allow-from.d.ts +20 -0
  3. package/dist/auth/allow-from.d.ts.map +1 -1
  4. package/dist/auth/allow-from.js +30 -0
  5. package/dist/auth/allow-from.js.map +1 -1
  6. package/dist/channels/channel.d.ts +6 -0
  7. package/dist/channels/channel.d.ts.map +1 -1
  8. package/dist/channels/discord.d.ts.map +1 -1
  9. package/dist/channels/discord.js +31 -7
  10. package/dist/channels/discord.js.map +1 -1
  11. package/dist/channels/telegram.d.ts +1 -0
  12. package/dist/channels/telegram.d.ts.map +1 -1
  13. package/dist/channels/telegram.js +3 -0
  14. package/dist/channels/telegram.js.map +1 -1
  15. package/dist/commands/registry.d.ts.map +1 -1
  16. package/dist/commands/registry.js +40 -0
  17. package/dist/commands/registry.js.map +1 -1
  18. package/dist/core/agent-loop.d.ts.map +1 -1
  19. package/dist/core/agent-loop.js +456 -441
  20. package/dist/core/agent-loop.js.map +1 -1
  21. package/dist/core/message-queue.d.ts.map +1 -1
  22. package/dist/core/message-queue.js +19 -9
  23. package/dist/core/message-queue.js.map +1 -1
  24. package/dist/core/prompt.d.ts.map +1 -1
  25. package/dist/core/prompt.js +3 -1
  26. package/dist/core/prompt.js.map +1 -1
  27. package/dist/core/reactions.d.ts +5 -5
  28. package/dist/core/reactions.js +10 -10
  29. package/dist/core/reactions.js.map +1 -1
  30. package/dist/core/retry-queue.d.ts +2 -0
  31. package/dist/core/retry-queue.d.ts.map +1 -1
  32. package/dist/core/retry-queue.js +15 -0
  33. package/dist/core/retry-queue.js.map +1 -1
  34. package/dist/gateway/compaction.d.ts +1 -0
  35. package/dist/gateway/compaction.d.ts.map +1 -1
  36. package/dist/gateway/compaction.js +5 -1
  37. package/dist/gateway/compaction.js.map +1 -1
  38. package/dist/gateway/gateway.d.ts.map +1 -1
  39. package/dist/gateway/gateway.js +5 -3
  40. package/dist/gateway/gateway.js.map +1 -1
  41. package/dist/gateway/session.d.ts.map +1 -1
  42. package/dist/gateway/session.js +7 -0
  43. package/dist/gateway/session.js.map +1 -1
  44. package/dist/index.js +91 -16
  45. package/dist/index.js.map +1 -1
  46. package/dist/providers/failover.d.ts.map +1 -1
  47. package/dist/providers/failover.js +8 -7
  48. package/dist/providers/failover.js.map +1 -1
  49. package/dist/tools/message.d.ts +10 -0
  50. package/dist/tools/message.d.ts.map +1 -1
  51. package/dist/tools/message.js +61 -13
  52. package/dist/tools/message.js.map +1 -1
  53. package/dist/tools/system-tools.d.ts.map +1 -1
  54. package/dist/tools/system-tools.js +26 -1
  55. package/dist/tools/system-tools.js.map +1 -1
  56. package/docs/INSTALL.md +7 -4
  57. package/docs/channels.md +56 -115
  58. package/package.json +1 -1
@@ -190,510 +190,525 @@ export class AgentLoop {
190
190
  return;
191
191
  }
192
192
  userMessage = sanitized_input.text;
193
+ // New inbound user turn supersedes pending retries for this session.
194
+ this.deps.retryQueue?.cancelSession(session.id);
193
195
  // 1a3. Start typing indicator
194
196
  const chatId = session.metadata?.channelTarget ?? session.metadata?.channelId ?? session.id;
195
197
  if (this.deps.typingManager && this.deps.channel) {
196
198
  this.deps.typingManager.start(this.deps.channel, chatId);
197
199
  }
198
- // 1b. React with 👀 (received)
199
- const messageId = session.metadata?.messageId;
200
- if (this.deps.reactionManager && this.deps.channel && messageId) {
201
- await this.deps.reactionManager.react(this.deps.channel, chatId, messageId, 'received');
202
- }
203
- // 1c. Check if message is a command
204
- const parsed = parseCommand(userMessage);
205
- if (parsed) {
206
- // /think <level> — set thinking level for this session
207
- if (parsed.command === 'think' && this.deps.thinkingManager) {
208
- const levels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
209
- const level = parsed.args.trim().toLowerCase();
210
- if (levels.includes(level)) {
211
- this.deps.thinkingManager.setLevel(session.id, level);
212
- this.deps.typingManager?.stop(chatId);
213
- yield `Thinking level set to **${level}** for this session.`;
214
- return;
215
- }
216
- this.deps.typingManager?.stop(chatId);
217
- yield `Invalid thinking level. Use one of: ${levels.join(', ')}`;
218
- return;
200
+ try {
201
+ // 1b. React with 👀 (received)
202
+ const messageId = session.metadata?.messageId;
203
+ if (this.deps.reactionManager && this.deps.channel && messageId) {
204
+ await this.deps.reactionManager.react(this.deps.channel, chatId, messageId, 'received');
219
205
  }
220
- // Built-in commands (/help, /status, etc.)
221
- if (this.deps.commandRegistry) {
222
- const builtinResult = await this.deps.commandRegistry.handle(userMessage, {
223
- channelId: session.metadata?.channelId ?? '',
224
- authorId: session.metadata?.authorId ?? '',
225
- authorName: session.metadata?.authorName ?? '',
226
- session,
227
- agentId: this.deps.agentId ?? '',
228
- });
229
- if (builtinResult !== null) {
206
+ // 1c. Check if message is a command
207
+ const parsed = parseCommand(userMessage);
208
+ if (parsed) {
209
+ // /think <level> — set thinking level for this session
210
+ if (parsed.command === 'think' && this.deps.thinkingManager) {
211
+ const levels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
212
+ const level = parsed.args.trim().toLowerCase();
213
+ if (levels.includes(level)) {
214
+ this.deps.thinkingManager.setLevel(session.id, level);
215
+ this.deps.typingManager?.stop(chatId);
216
+ yield `Thinking level set to **${level}** for this session.`;
217
+ return;
218
+ }
230
219
  this.deps.typingManager?.stop(chatId);
231
- yield builtinResult;
220
+ yield `Invalid thinking level. Use one of: ${levels.join(', ')}`;
232
221
  return;
233
222
  }
234
- }
235
- // Skill commands
236
- if (this.deps.skillCommandSpecs && this.deps.skillCommandSpecs.length > 0 && skillLoader) {
237
- const dispatchCtx = {
238
- toolRegistry,
239
- skillLoader,
240
- toolContext: {
241
- sessionId: session.id,
242
- workDir: this.deps.workspaceRoot ?? process.cwd(),
243
- workspaceRoot: this.deps.workspaceRoot ?? process.cwd(),
244
- agentId: this.deps.agentId,
245
- agentRole: this.deps.agentRole ?? 'admin',
246
- },
247
- };
248
- const result = await dispatchSkillCommand(parsed, this.deps.skillCommandSpecs, dispatchCtx);
249
- if (result.kind === 'tool-result') {
250
- this.deps.typingManager?.stop(chatId);
251
- yield result.response ?? '';
252
- return;
223
+ // Built-in commands (/help, /status, etc.)
224
+ if (this.deps.commandRegistry) {
225
+ const builtinResult = await this.deps.commandRegistry.handle(userMessage, {
226
+ channelId: session.metadata?.channelId ?? '',
227
+ authorId: session.metadata?.authorId ?? '',
228
+ authorName: session.metadata?.authorName ?? '',
229
+ session,
230
+ agentId: this.deps.agentId ?? '',
231
+ });
232
+ if (builtinResult !== null) {
233
+ this.deps.typingManager?.stop(chatId);
234
+ yield builtinResult;
235
+ return;
236
+ }
253
237
  }
254
- if (result.kind === 'skill-inject') {
255
- // Rewrite userMessage with the args and inject skill instructions below
256
- userMessage = result.forwardMessage ?? userMessage;
257
- // We'll inject the skill instructions when building the system prompt
258
- // Store it so the prompt builder can pick it up
259
- session.metadata = {
260
- ...session.metadata,
261
- _injectedSkillInstructions: result.instructions,
262
- _injectedSkillName: result.skillName,
238
+ // Skill commands
239
+ if (this.deps.skillCommandSpecs && this.deps.skillCommandSpecs.length > 0 && skillLoader) {
240
+ const dispatchCtx = {
241
+ toolRegistry,
242
+ skillLoader,
243
+ toolContext: {
244
+ sessionId: session.id,
245
+ workDir: this.deps.workspaceRoot ?? process.cwd(),
246
+ workspaceRoot: this.deps.workspaceRoot ?? process.cwd(),
247
+ agentId: this.deps.agentId,
248
+ agentRole: this.deps.agentRole ?? 'admin',
249
+ },
263
250
  };
251
+ const result = await dispatchSkillCommand(parsed, this.deps.skillCommandSpecs, dispatchCtx);
252
+ if (result.kind === 'tool-result') {
253
+ this.deps.typingManager?.stop(chatId);
254
+ yield result.response ?? '';
255
+ return;
256
+ }
257
+ if (result.kind === 'skill-inject') {
258
+ // Rewrite userMessage with the args and inject skill instructions below
259
+ userMessage = result.forwardMessage ?? userMessage;
260
+ // We'll inject the skill instructions when building the system prompt
261
+ // Store it so the prompt builder can pick it up
262
+ session.metadata = {
263
+ ...session.metadata,
264
+ _injectedSkillInstructions: result.instructions,
265
+ _injectedSkillName: result.skillName,
266
+ };
267
+ }
264
268
  }
265
269
  }
266
- }
267
- // 1d. Transition reaction to processing
268
- if (this.deps.reactionManager && this.deps.channel && messageId) {
269
- await this.deps.reactionManager.transition(this.deps.channel, chatId, messageId, 'received', 'processing');
270
- }
271
- // 2. Build system prompt
272
- if (hooks) {
273
- await hooks.emit('before_prompt_build', {
274
- event: 'before_prompt_build',
275
- sessionId: session.id,
276
- data: {},
277
- timestamp: Date.now(),
278
- });
279
- }
280
- // Inject self-awareness context into prompt builder
281
- promptBuilder.setTools(toolRegistry.getActiveTools());
282
- if (skillLoader) {
283
- promptBuilder.setSkills(skillLoader.getAll());
284
- }
285
- if (this.deps.model) {
286
- promptBuilder.setModel(this.deps.model);
287
- }
288
- if (this.deps.workspaceRoot) {
289
- promptBuilder.setWorkingDir(this.deps.workspaceRoot);
290
- }
291
- // Inject timezone context
292
- if (this.deps.timezoneManager) {
293
- promptBuilder.setTimezoneContext(this.deps.timezoneManager.getContextString());
294
- }
295
- let systemPrompt = await promptBuilder.build({ mode: 'full' });
296
- // Dynamic skill injection: check trigger conditions against the user message
297
- if (skillLoader) {
298
- const matchingSkills = skillLoader.getMatchingSkills(userMessage);
299
- for (const skill of matchingSkills) {
300
- // Avoid duplicating instructions already in the base prompt
301
- const snippet = skill.instructions.slice(0, 100);
302
- if (!systemPrompt.includes(snippet)) {
303
- systemPrompt += `\n\n---\n\n# Skill: ${skill.manifest.name}\n\n${skill.instructions}`;
270
+ // 1d. Transition reaction to processing
271
+ if (this.deps.reactionManager && this.deps.channel && messageId) {
272
+ await this.deps.reactionManager.transition(this.deps.channel, chatId, messageId, 'received', 'processing');
273
+ }
274
+ // 2. Build system prompt
275
+ if (hooks) {
276
+ await hooks.emit('before_prompt_build', {
277
+ event: 'before_prompt_build',
278
+ sessionId: session.id,
279
+ data: {},
280
+ timestamp: Date.now(),
281
+ });
282
+ }
283
+ // Inject self-awareness context into prompt builder
284
+ promptBuilder.setTools(toolRegistry.getActiveTools());
285
+ if (skillLoader) {
286
+ promptBuilder.setSkills(skillLoader.getAll());
287
+ }
288
+ if (this.deps.model) {
289
+ promptBuilder.setModel(this.deps.model);
290
+ }
291
+ if (this.deps.workspaceRoot) {
292
+ promptBuilder.setWorkingDir(this.deps.workspaceRoot);
293
+ }
294
+ // Inject timezone context
295
+ if (this.deps.timezoneManager) {
296
+ promptBuilder.setTimezoneContext(this.deps.timezoneManager.getContextString());
297
+ }
298
+ let systemPrompt = await promptBuilder.build({ mode: 'full' });
299
+ // Dynamic skill injection: check trigger conditions against the user message
300
+ if (skillLoader) {
301
+ const matchingSkills = skillLoader.getMatchingSkills(userMessage);
302
+ for (const skill of matchingSkills) {
303
+ // Avoid duplicating instructions already in the base prompt
304
+ const snippet = skill.instructions.slice(0, 100);
305
+ if (!systemPrompt.includes(snippet)) {
306
+ systemPrompt += `\n\n---\n\n# Skill: ${skill.manifest.name}\n\n${skill.instructions}`;
307
+ }
304
308
  }
305
309
  }
306
- }
307
- // Inject skill instructions from command dispatch (if applicable)
308
- const injectedInstructions = session.metadata?._injectedSkillInstructions;
309
- const injectedSkillName = session.metadata?._injectedSkillName;
310
- if (injectedInstructions && injectedSkillName) {
311
- const snippet = injectedInstructions.slice(0, 100);
312
- if (!systemPrompt.includes(snippet)) {
313
- systemPrompt += `\n\n---\n\n# Skill: ${injectedSkillName}\n\n${injectedInstructions}`;
310
+ // Inject skill instructions from command dispatch (if applicable)
311
+ const injectedInstructions = session.metadata?._injectedSkillInstructions;
312
+ const injectedSkillName = session.metadata?._injectedSkillName;
313
+ if (injectedInstructions && injectedSkillName) {
314
+ const snippet = injectedInstructions.slice(0, 100);
315
+ if (!systemPrompt.includes(snippet)) {
316
+ systemPrompt += `\n\n---\n\n# Skill: ${injectedSkillName}\n\n${injectedInstructions}`;
317
+ }
318
+ // Clean up metadata
319
+ delete session.metadata?._injectedSkillInstructions;
320
+ delete session.metadata?._injectedSkillName;
314
321
  }
315
- // Clean up metadata
316
- delete session.metadata?._injectedSkillInstructions;
317
- delete session.metadata?._injectedSkillName;
318
- }
319
- // 3. Append user message to session (with image attachments as content blocks)
320
- const imageAttachments = attachments?.filter((a) => (a.type === 'image' || a.mimeType?.startsWith('image/')) && a.url) ?? [];
321
- if (imageAttachments.length > 0) {
322
- // Download images and convert to base64 for provider compatibility
323
- const imageParts = [];
324
- for (const a of imageAttachments) {
325
- try {
326
- const resp = await fetch(a.url);
327
- if (resp.ok) {
328
- const buffer = Buffer.from(await resp.arrayBuffer());
329
- const mediaType = a.mimeType || resp.headers.get('content-type') || 'image/png';
330
- imageParts.push({
331
- type: 'image_base64',
332
- media_type: mediaType,
333
- data: buffer.toString('base64'),
334
- });
322
+ // 3. Append user message to session (with image attachments as content blocks)
323
+ const imageAttachments = attachments?.filter((a) => (a.type === 'image' || a.mimeType?.startsWith('image/')) && a.url) ?? [];
324
+ if (imageAttachments.length > 0) {
325
+ // Download images and convert to base64 for provider compatibility
326
+ const imageParts = [];
327
+ for (const a of imageAttachments) {
328
+ try {
329
+ const resp = await fetch(a.url);
330
+ if (resp.ok) {
331
+ const buffer = Buffer.from(await resp.arrayBuffer());
332
+ const mediaType = a.mimeType || resp.headers.get('content-type') || 'image/png';
333
+ imageParts.push({
334
+ type: 'image_base64',
335
+ media_type: mediaType,
336
+ data: buffer.toString('base64'),
337
+ });
338
+ }
339
+ else {
340
+ console.warn(`[agent-loop] Failed to fetch image ${a.url}: HTTP ${resp.status}`);
341
+ }
335
342
  }
336
- else {
337
- console.warn(`[agent-loop] Failed to fetch image ${a.url}: HTTP ${resp.status}`);
343
+ catch (err) {
344
+ console.warn(`[agent-loop] Failed to fetch image ${a.url}:`, err instanceof Error ? err.message : err);
338
345
  }
339
346
  }
340
- catch (err) {
341
- console.warn(`[agent-loop] Failed to fetch image ${a.url}:`, err instanceof Error ? err.message : err);
347
+ if (imageParts.length > 0) {
348
+ const contentParts = [
349
+ { type: 'text', text: userMessage || 'What is in this image?' },
350
+ ...imageParts,
351
+ ];
352
+ session.messages.push({ role: 'user', content: contentParts });
353
+ }
354
+ else {
355
+ session.messages.push({ role: 'user', content: userMessage });
342
356
  }
343
- }
344
- if (imageParts.length > 0) {
345
- const contentParts = [
346
- { type: 'text', text: userMessage || 'What is in this image?' },
347
- ...imageParts,
348
- ];
349
- session.messages.push({ role: 'user', content: contentParts });
350
357
  }
351
358
  else {
352
359
  session.messages.push({ role: 'user', content: userMessage });
353
360
  }
354
- }
355
- else {
356
- session.messages.push({ role: 'user', content: userMessage });
357
- }
358
- // 4. Assemble context
359
- const messages = [
360
- { role: 'system', content: systemPrompt },
361
- ...session.messages,
362
- ];
363
- // 5. Get tool definitions
364
- const tools = toolRegistry.getActiveTools().map((t) => ({
365
- name: t.name,
366
- description: t.description,
367
- parameters: t.parameters,
368
- }));
369
- // 6. Agent loop (may iterate if tool calls occur)
370
- let turns = 0;
371
- while (turns < this.config.maxTurns) {
372
- turns++;
373
- if (contextManager.needsCompaction(messages) && this.deps.compactor) {
374
- const compactionResult = await this.deps.compactor.checkAndCompact(session);
375
- // Rebuild messages array from compacted session
376
- messages.length = 0;
377
- messages.push({ role: 'system', content: systemPrompt }, ...session.messages);
378
- // Inject session init context after compaction
379
- if (compactionResult && this.deps.progressTracker) {
380
- const initContext = await buildSessionInitContext(this.deps.progressTracker, this.deps.sessionInitConfig);
381
- const initMsg = { role: 'system', content: initContext };
382
- session.messages.push(initMsg);
383
- messages.push(initMsg);
361
+ // 4. Assemble context
362
+ const messages = [
363
+ { role: 'system', content: systemPrompt },
364
+ ...session.messages,
365
+ ];
366
+ // 5. Get tool definitions
367
+ const tools = toolRegistry.getActiveTools().map((t) => ({
368
+ name: t.name,
369
+ description: t.description,
370
+ parameters: t.parameters,
371
+ }));
372
+ // 6. Agent loop (may iterate if tool calls occur)
373
+ let turns = 0;
374
+ while (turns < this.config.maxTurns) {
375
+ turns++;
376
+ // Progressive pruning before compaction to reduce context pressure.
377
+ const pruningResult = contextManager.pruneMessages(session.messages);
378
+ if (pruningResult.tokensSaved > 0) {
379
+ session.messages = pruningResult.messages;
380
+ messages.length = 0;
381
+ messages.push({ role: 'system', content: systemPrompt }, ...session.messages);
384
382
  }
385
- }
386
- const model = this.deps.model ?? 'anthropic/claude-sonnet-4-6';
387
- // Sanitize messages: merge consecutive same-role messages for API compatibility
388
- const sanitized = this.sanitizeMessages(messages);
389
- let fullText = '';
390
- let pendingToolCalls = [];
391
- const turnStartMs = Date.now();
392
- let lastUsage;
393
- // Set up streaming if a channel is available and streaming is enabled
394
- const streamingCfg = this.deps.streamingConfig;
395
- const channelRef = session.metadata?.channelRef;
396
- const channelTarget = session.metadata?.channelTarget;
397
- // reference runtime-style default: streaming off unless explicitly enabled in config
398
- const streamEnabled = streamingCfg?.enabled === true && channelRef && channelTarget;
399
- let streamer;
400
- if (streamEnabled) {
401
- const maxLen = channelRef.id === 'telegram' ? 4096 : 2000;
402
- streamer = new ResponseStreamer({
403
- enabled: true,
404
- minChunkSize: streamingCfg?.minChunkSize ?? 50,
405
- flushIntervalMs: streamingCfg?.flushIntervalMs ?? 500,
406
- maxMessageLength: maxLen,
407
- }, { channelId: channelTarget, channel: channelRef });
408
- }
409
- // Collect chunks only yield text to caller on final turn (no tool calls)
410
- const chunks = [];
411
- try {
412
- for await (const chunk of provider.chat({
413
- model,
414
- messages: sanitized,
415
- tools: tools.length > 0 ? tools : undefined,
416
- stream: true,
417
- ...(this.config.maxTokens != null && { max_tokens: this.config.maxTokens }),
418
- })) {
419
- if (chunk.text) {
420
- fullText += chunk.text;
421
- chunks.push(chunk.text);
422
- // Push to streamer for real-time delivery
423
- if (streamer) {
424
- await streamer.push(chunk.text);
383
+ if (this.deps.compactor && this.deps.compactor.needsCompaction(session)) {
384
+ const compactionResult = await this.deps.compactor.checkAndCompact(session);
385
+ // Rebuild messages array from compacted session
386
+ messages.length = 0;
387
+ messages.push({ role: 'system', content: systemPrompt }, ...session.messages);
388
+ // Inject session init context after compaction
389
+ if (compactionResult && this.deps.progressTracker) {
390
+ const initContext = await buildSessionInitContext(this.deps.progressTracker, this.deps.sessionInitConfig);
391
+ const initMsg = { role: 'system', content: initContext };
392
+ session.messages.push(initMsg);
393
+ messages.push(initMsg);
394
+ }
395
+ }
396
+ const model = this.deps.model ?? 'anthropic/claude-sonnet-4-6';
397
+ // Sanitize messages: merge consecutive same-role messages for API compatibility
398
+ const sanitized = this.sanitizeMessages(messages);
399
+ let fullText = '';
400
+ let pendingToolCalls = [];
401
+ const turnStartMs = Date.now();
402
+ let lastUsage;
403
+ // Set up streaming if a channel is available and streaming is enabled
404
+ const streamingCfg = this.deps.streamingConfig;
405
+ const channelRef = session.metadata?.channelRef;
406
+ const channelTarget = session.metadata?.channelTarget;
407
+ // reference runtime-style default: streaming off unless explicitly enabled in config
408
+ const streamEnabled = streamingCfg?.enabled === true && channelRef && channelTarget;
409
+ let streamer;
410
+ if (streamEnabled) {
411
+ const maxLen = channelRef.id === 'telegram' ? 4096 : 2000;
412
+ streamer = new ResponseStreamer({
413
+ enabled: true,
414
+ minChunkSize: streamingCfg?.minChunkSize ?? 50,
415
+ flushIntervalMs: streamingCfg?.flushIntervalMs ?? 500,
416
+ maxMessageLength: maxLen,
417
+ }, { channelId: channelTarget, channel: channelRef });
418
+ }
419
+ // Collect chunks — only yield text to caller on final turn (no tool calls)
420
+ const chunks = [];
421
+ try {
422
+ for await (const chunk of provider.chat({
423
+ model,
424
+ messages: sanitized,
425
+ tools: tools.length > 0 ? tools : undefined,
426
+ stream: true,
427
+ ...(this.config.maxTokens != null && { max_tokens: this.config.maxTokens }),
428
+ })) {
429
+ if (chunk.text) {
430
+ fullText += chunk.text;
431
+ chunks.push(chunk.text);
432
+ // Push to streamer for real-time delivery
433
+ if (streamer) {
434
+ await streamer.push(chunk.text);
435
+ }
436
+ }
437
+ if (chunk.tool_calls) {
438
+ pendingToolCalls.push(...chunk.tool_calls);
439
+ }
440
+ if (chunk.done && chunk.usage) {
441
+ lastUsage = chunk.usage;
425
442
  }
426
443
  }
427
- if (chunk.tool_calls) {
428
- pendingToolCalls.push(...chunk.tool_calls);
444
+ }
445
+ catch (err) {
446
+ // Cancel streamer on error
447
+ if (streamer)
448
+ await streamer.cancel();
449
+ // All model providers failed — enqueue for retry if available
450
+ if (this.deps.retryQueue) {
451
+ this.deps.retryQueue.enqueue({
452
+ userMessage,
453
+ sessionId: session.id,
454
+ channelId: session.metadata?.channelId,
455
+ messageId: session.metadata?.messageId,
456
+ channel: session.metadata?.channelRef,
457
+ });
429
458
  }
430
- if (chunk.done && chunk.usage) {
431
- lastUsage = chunk.usage;
459
+ this.deps.typingManager?.stop(chatId);
460
+ // Transition reaction to failed
461
+ if (this.deps.reactionManager && this.deps.channel && messageId) {
462
+ await this.deps.reactionManager.transition(this.deps.channel, chatId, messageId, 'processing', 'failed');
432
463
  }
464
+ throw err;
433
465
  }
434
- }
435
- catch (err) {
436
- // Cancel streamer on error
437
- if (streamer)
438
- await streamer.cancel();
439
- // All model providers failed — enqueue for retry if available
440
- if (this.deps.retryQueue) {
441
- this.deps.retryQueue.enqueue({
442
- userMessage,
466
+ // Finish streaming for this turn (flush remaining buffer)
467
+ if (streamer) {
468
+ await streamer.finish();
469
+ }
470
+ // Record usage for this turn
471
+ if (this.deps.usageTracker && lastUsage) {
472
+ const cachedTokens = (lastUsage.cache_read_input_tokens ?? 0);
473
+ this.deps.usageTracker.record({
443
474
  sessionId: session.id,
444
- channelId: session.metadata?.channelId,
445
- messageId: session.metadata?.messageId,
446
- channel: session.metadata?.channelRef,
475
+ model,
476
+ provider: model.includes('/') ? model.split('/')[0] : 'unknown',
477
+ timestamp: Date.now(),
478
+ inputTokens: lastUsage.prompt_tokens,
479
+ outputTokens: lastUsage.completion_tokens,
480
+ cachedTokens,
481
+ totalTokens: lastUsage.total_tokens,
482
+ durationMs: Date.now() - turnStartMs,
447
483
  });
448
484
  }
449
- this.deps.typingManager?.stop(chatId);
450
- // Transition reaction to failed
451
- if (this.deps.reactionManager && this.deps.channel && messageId) {
452
- await this.deps.reactionManager.transition(this.deps.channel, chatId, messageId, 'processing', 'failed');
453
- }
454
- throw err;
455
- }
456
- // Finish streaming for this turn (flush remaining buffer)
457
- if (streamer) {
458
- await streamer.finish();
459
- }
460
- // Record usage for this turn
461
- if (this.deps.usageTracker && lastUsage) {
462
- const cachedTokens = (lastUsage.cache_read_input_tokens ?? 0);
463
- this.deps.usageTracker.record({
464
- sessionId: session.id,
465
- model,
466
- provider: model.includes('/') ? model.split('/')[0] : 'unknown',
467
- timestamp: Date.now(),
468
- inputTokens: lastUsage.prompt_tokens,
469
- outputTokens: lastUsage.completion_tokens,
470
- cachedTokens,
471
- totalTokens: lastUsage.total_tokens,
472
- durationMs: Date.now() - turnStartMs,
473
- });
474
- }
475
- // Scan model output for secrets before yielding
476
- if (fullText) {
477
- const scannedText = scanSecrets(fullText);
478
- if (scannedText !== fullText) {
479
- // Rebuild chunks from scanned text
480
- chunks.length = 0;
481
- chunks.push(scannedText);
482
- fullText = scannedText;
483
- }
484
- }
485
- // Only yield text from the final turn (no pending tool calls).
486
- // Intermediate turns often repeat the same intro text before each tool call,
487
- // which clutters the output. The final turn has the actual response.
488
- if (pendingToolCalls.length === 0 && !fullText && turns > 1) {
489
- // Final turn after tool calls produced no text — yield a minimal acknowledgment
490
- // so the user isn't left with silence.
491
- console.warn(`[agent-loop] Turn ${turns}: no text after tool calls (${chunks.length} chunks, fullText empty)`);
492
- if (!streamer)
493
- yield '✅ Done.';
494
- }
495
- if (pendingToolCalls.length === 0 && !streamer) {
496
- let emitted = 0;
497
- for (const c of chunks) {
498
- const remaining = this.config.maxOutputChars - emitted;
499
- if (remaining <= 0) {
500
- yield '\n\n[Output truncated — response exceeded character limit]';
501
- break;
502
- }
503
- const accepted = c.length <= remaining ? c : c.slice(0, remaining);
504
- yield accepted;
505
- emitted += accepted.length;
506
- if (accepted.length < c.length) {
507
- yield '\n\n[Output truncated — response exceeded character limit]';
508
- break;
485
+ // Scan model output for secrets before yielding
486
+ if (fullText) {
487
+ const scannedText = scanSecrets(fullText);
488
+ if (scannedText !== fullText) {
489
+ // Rebuild chunks from scanned text
490
+ chunks.length = 0;
491
+ chunks.push(scannedText);
492
+ fullText = scannedText;
509
493
  }
510
494
  }
511
- }
512
- // Append assistant message to session history.
513
- // For intermediate turns (with tool calls), strip the preamble text to prevent
514
- // context pollution the model sees its own repeated intros and reinforces them.
515
- // Only keep tool_use blocks in intermediate turns; final turn keeps full text.
516
- if (fullText || pendingToolCalls.length > 0) {
517
- const contentParts = [];
518
- if (pendingToolCalls.length > 0) {
519
- // Intermediate turn: replace verbose preamble with a short marker.
520
- // This prevents the model from seeing N copies of "I'll help you..." in context.
521
- contentParts.push({ type: 'text', text: '[Calling tools]' });
495
+ // Only yield text from the final turn (no pending tool calls).
496
+ // Intermediate turns often repeat the same intro text before each tool call,
497
+ // which clutters the output. The final turn has the actual response.
498
+ if (pendingToolCalls.length === 0 && !fullText && turns > 1) {
499
+ // Final turn after tool calls produced no text yield a minimal acknowledgment
500
+ // so the user isn't left with silence.
501
+ console.warn(`[agent-loop] Turn ${turns}: no text after tool calls (${chunks.length} chunks, fullText empty)`);
502
+ if (!streamer)
503
+ yield '✅ Done.';
522
504
  }
523
- else if (fullText) {
524
- contentParts.push({ type: 'text', text: fullText });
505
+ if (pendingToolCalls.length === 0 && !streamer) {
506
+ let emitted = 0;
507
+ for (const c of chunks) {
508
+ const remaining = this.config.maxOutputChars - emitted;
509
+ if (remaining <= 0) {
510
+ yield '\n\n[Output truncated — response exceeded character limit]';
511
+ break;
512
+ }
513
+ const accepted = c.length <= remaining ? c : c.slice(0, remaining);
514
+ yield accepted;
515
+ emitted += accepted.length;
516
+ if (accepted.length < c.length) {
517
+ yield '\n\n[Output truncated — response exceeded character limit]';
518
+ break;
519
+ }
520
+ }
525
521
  }
526
- for (const tc of pendingToolCalls) {
527
- contentParts.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input });
522
+ // Append assistant message to session history.
523
+ // For intermediate turns (with tool calls), strip the preamble text to prevent
524
+ // context pollution — the model sees its own repeated intros and reinforces them.
525
+ // Only keep tool_use blocks in intermediate turns; final turn keeps full text.
526
+ if (fullText || pendingToolCalls.length > 0) {
527
+ const contentParts = [];
528
+ if (pendingToolCalls.length > 0) {
529
+ // Intermediate turn: replace verbose preamble with a short marker.
530
+ // This prevents the model from seeing N copies of "I'll help you..." in context.
531
+ contentParts.push({ type: 'text', text: '[Calling tools]' });
532
+ }
533
+ else if (fullText) {
534
+ contentParts.push({ type: 'text', text: fullText });
535
+ }
536
+ for (const tc of pendingToolCalls) {
537
+ contentParts.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input });
538
+ }
539
+ const assistantMsg = {
540
+ role: 'assistant',
541
+ content: pendingToolCalls.length > 0 ? contentParts : fullText,
542
+ };
543
+ session.messages.push(assistantMsg);
544
+ messages.push(assistantMsg);
528
545
  }
529
- const assistantMsg = {
530
- role: 'assistant',
531
- content: pendingToolCalls.length > 0 ? contentParts : fullText,
546
+ if (pendingToolCalls.length === 0)
547
+ break;
548
+ // Execute tool calls
549
+ const roleName = this.deps.agentRole ?? 'admin';
550
+ const role = getRole(roleName);
551
+ const toolCtx = {
552
+ sessionId: session.id,
553
+ workDir: this.deps.workspaceRoot ?? process.cwd(),
554
+ workspaceRoot: this.deps.workspaceRoot ?? process.cwd(),
555
+ agentId: this.deps.agentId,
556
+ agentRole: roleName,
557
+ channelType: session.metadata?.channelType,
558
+ channelTarget: session.metadata?.channelTarget,
559
+ channel: session.metadata?.channelRef,
532
560
  };
533
- session.messages.push(assistantMsg);
534
- messages.push(assistantMsg);
535
- }
536
- if (pendingToolCalls.length === 0)
537
- break;
538
- // Execute tool calls
539
- const roleName = this.deps.agentRole ?? 'admin';
540
- const role = getRole(roleName);
541
- const toolCtx = {
542
- sessionId: session.id,
543
- workDir: this.deps.workspaceRoot ?? process.cwd(),
544
- workspaceRoot: this.deps.workspaceRoot ?? process.cwd(),
545
- agentId: this.deps.agentId,
546
- agentRole: roleName,
547
- channelType: session.metadata?.channelType,
548
- channelTarget: session.metadata?.channelTarget,
549
- channel: session.metadata?.channelRef,
550
- };
551
- for (const tc of pendingToolCalls) {
552
- // Check for tool loops before execution
553
- if (this.deps.toolLoopDetector) {
554
- const loopWarning = this.deps.toolLoopDetector.recordAndCheck(session.id, tc.name, (tc.input ?? {}));
555
- if (loopWarning) {
556
- const loopMsg = { role: 'system', content: loopWarning };
557
- session.messages.push(loopMsg);
558
- messages.push(loopMsg);
561
+ for (const tc of pendingToolCalls) {
562
+ // Check for tool loops before execution
563
+ if (this.deps.toolLoopDetector) {
564
+ const loopWarning = this.deps.toolLoopDetector.recordAndCheck(session.id, tc.name, (tc.input ?? {}));
565
+ if (loopWarning) {
566
+ const loopMsg = { role: 'system', content: loopWarning };
567
+ session.messages.push(loopMsg);
568
+ messages.push(loopMsg);
569
+ const toolMsg = {
570
+ role: 'tool',
571
+ content: loopWarning,
572
+ tool_call_id: tc.id,
573
+ };
574
+ session.messages.push(toolMsg);
575
+ messages.push(toolMsg);
576
+ continue;
577
+ }
578
+ }
579
+ // Check role-based permissions before execution
580
+ if (role && !isToolAllowed(role, tc.name)) {
581
+ const deniedResult = {
582
+ output: `Permission denied: agent role "${roleName}" cannot use tool "${tc.name}"`,
583
+ success: false,
584
+ };
585
+ if (hooks) {
586
+ await hooks.emit('after_tool_call', {
587
+ event: 'after_tool_call',
588
+ sessionId: session.id,
589
+ data: { toolName: tc.name, result: deniedResult },
590
+ timestamp: Date.now(),
591
+ });
592
+ }
559
593
  const toolMsg = {
560
594
  role: 'tool',
561
- content: loopWarning,
595
+ content: deniedResult.output,
562
596
  tool_call_id: tc.id,
563
597
  };
564
598
  session.messages.push(toolMsg);
565
599
  messages.push(toolMsg);
566
600
  continue;
567
601
  }
568
- }
569
- // Check role-based permissions before execution
570
- if (role && !isToolAllowed(role, tc.name)) {
571
- const deniedResult = {
572
- output: `Permission denied: agent role "${roleName}" cannot use tool "${tc.name}"`,
573
- success: false,
574
- };
575
602
  if (hooks) {
576
- await hooks.emit('after_tool_call', {
577
- event: 'after_tool_call',
603
+ await hooks.emit('before_tool_call', {
604
+ event: 'before_tool_call',
578
605
  sessionId: session.id,
579
- data: { toolName: tc.name, result: deniedResult },
606
+ data: { toolName: tc.name, params: tc.input },
580
607
  timestamp: Date.now(),
581
608
  });
582
609
  }
583
- const toolMsg = {
584
- role: 'tool',
585
- content: deniedResult.output,
586
- tool_call_id: tc.id,
587
- };
588
- session.messages.push(toolMsg);
589
- messages.push(toolMsg);
590
- continue;
591
- }
592
- if (hooks) {
593
- await hooks.emit('before_tool_call', {
594
- event: 'before_tool_call',
595
- sessionId: session.id,
596
- data: { toolName: tc.name, params: tc.input },
597
- timestamp: Date.now(),
598
- });
599
- }
600
- // Validate tool arguments before execution
601
- const validator = getToolValidator();
602
- if (validator && tc.input && typeof tc.input === 'object') {
603
- const input = tc.input;
604
- // Validate path arguments
605
- if (typeof input.path === 'string') {
606
- const pathCheck = validator.validatePath(input.path, toolCtx.workDir, ['write', 'edit', 'apply_patch'].includes(tc.name));
607
- if (!pathCheck.allowed) {
608
- const toolMsg = {
609
- role: 'tool',
610
- content: `Blocked: ${pathCheck.blockReason}`,
611
- tool_call_id: tc.id,
612
- };
613
- session.messages.push(toolMsg);
614
- messages.push(toolMsg);
615
- continue;
610
+ // Validate tool arguments before execution
611
+ const validator = getToolValidator();
612
+ if (validator && tc.input && typeof tc.input === 'object') {
613
+ const input = tc.input;
614
+ // Validate path arguments
615
+ if (typeof input.path === 'string') {
616
+ const pathCheck = validator.validatePath(input.path, toolCtx.workDir, ['write', 'edit', 'apply_patch'].includes(tc.name));
617
+ if (!pathCheck.allowed) {
618
+ const toolMsg = {
619
+ role: 'tool',
620
+ content: `Blocked: ${pathCheck.blockReason}`,
621
+ tool_call_id: tc.id,
622
+ };
623
+ session.messages.push(toolMsg);
624
+ messages.push(toolMsg);
625
+ continue;
626
+ }
616
627
  }
617
- }
618
- // Validate command arguments
619
- if (typeof input.command === 'string' && ['exec', 'process'].includes(tc.name)) {
620
- const cmdCheck = validator.validateCommand(input.command);
621
- if (!cmdCheck.allowed) {
622
- const toolMsg = {
623
- role: 'tool',
624
- content: `Blocked: ${cmdCheck.blockReason}`,
625
- tool_call_id: tc.id,
626
- };
627
- session.messages.push(toolMsg);
628
- messages.push(toolMsg);
629
- continue;
628
+ // Validate command arguments
629
+ if (typeof input.command === 'string' && ['exec', 'process'].includes(tc.name)) {
630
+ const cmdCheck = validator.validateCommand(input.command);
631
+ if (!cmdCheck.allowed) {
632
+ const toolMsg = {
633
+ role: 'tool',
634
+ content: `Blocked: ${cmdCheck.blockReason}`,
635
+ tool_call_id: tc.id,
636
+ };
637
+ session.messages.push(toolMsg);
638
+ messages.push(toolMsg);
639
+ continue;
640
+ }
641
+ }
642
+ // Validate URL arguments
643
+ if (typeof input.url === 'string' && ['web_fetch', 'browser_navigate'].includes(tc.name)) {
644
+ const urlCheck = validator.validateUrl(input.url);
645
+ if (!urlCheck.allowed) {
646
+ const toolMsg = {
647
+ role: 'tool',
648
+ content: `Blocked: ${urlCheck.blockReason}`,
649
+ tool_call_id: tc.id,
650
+ };
651
+ session.messages.push(toolMsg);
652
+ messages.push(toolMsg);
653
+ continue;
654
+ }
630
655
  }
631
656
  }
632
- // Validate URL arguments
633
- if (typeof input.url === 'string' && ['web_fetch', 'browser_navigate'].includes(tc.name)) {
634
- const urlCheck = validator.validateUrl(input.url);
635
- if (!urlCheck.allowed) {
636
- const toolMsg = {
637
- role: 'tool',
638
- content: `Blocked: ${urlCheck.blockReason}`,
639
- tool_call_id: tc.id,
640
- };
641
- session.messages.push(toolMsg);
642
- messages.push(toolMsg);
643
- continue;
657
+ const tool = toolRegistry.getTool(tc.name);
658
+ let result;
659
+ try {
660
+ if (tool) {
661
+ result = await tool.execute(tc.input, toolCtx);
662
+ }
663
+ else {
664
+ result = { output: `Unknown tool: ${tc.name}`, success: false };
644
665
  }
645
666
  }
646
- }
647
- const tool = toolRegistry.getTool(tc.name);
648
- let result;
649
- try {
650
- if (tool) {
651
- result = await tool.execute(tc.input, toolCtx);
667
+ catch (err) {
668
+ result = {
669
+ output: `Error executing ${tc.name}: ${err instanceof Error ? err.message : String(err)}`,
670
+ success: false,
671
+ error: err instanceof Error ? err.message : String(err),
672
+ };
652
673
  }
653
- else {
654
- result = { output: `Unknown tool: ${tc.name}`, success: false };
674
+ if (hooks) {
675
+ await hooks.emit('after_tool_call', {
676
+ event: 'after_tool_call',
677
+ sessionId: session.id,
678
+ data: { toolName: tc.name, result },
679
+ timestamp: Date.now(),
680
+ });
655
681
  }
656
- }
657
- catch (err) {
658
- result = {
659
- output: `Error executing ${tc.name}: ${err instanceof Error ? err.message : String(err)}`,
660
- success: false,
661
- error: err instanceof Error ? err.message : String(err),
682
+ const toolMsg = {
683
+ role: 'tool',
684
+ content: result.output,
685
+ tool_call_id: tc.id,
662
686
  };
687
+ session.messages.push(toolMsg);
688
+ messages.push(toolMsg);
663
689
  }
664
- if (hooks) {
665
- await hooks.emit('after_tool_call', {
666
- event: 'after_tool_call',
667
- sessionId: session.id,
668
- data: { toolName: tc.name, result },
669
- timestamp: Date.now(),
670
- });
671
- }
672
- const toolMsg = {
673
- role: 'tool',
674
- content: result.output,
675
- tool_call_id: tc.id,
676
- };
677
- session.messages.push(toolMsg);
678
- messages.push(toolMsg);
690
+ pendingToolCalls = [];
691
+ }
692
+ // Stop typing indicator
693
+ this.deps.typingManager?.stop(chatId);
694
+ // Transition reaction to completed
695
+ if (this.deps.reactionManager && this.deps.channel && messageId) {
696
+ await this.deps.reactionManager.transition(this.deps.channel, chatId, messageId, 'processing', 'completed');
697
+ }
698
+ // Clear tool loop history for this session turn
699
+ this.deps.toolLoopDetector?.clearSession(session.id);
700
+ if (hooks) {
701
+ await hooks.emit('agent_end', {
702
+ event: 'agent_end',
703
+ sessionId: session.id,
704
+ data: { turns },
705
+ timestamp: Date.now(),
706
+ });
679
707
  }
680
- pendingToolCalls = [];
681
- }
682
- // Stop typing indicator
683
- this.deps.typingManager?.stop(chatId);
684
- // Transition reaction to completed
685
- if (this.deps.reactionManager && this.deps.channel && messageId) {
686
- await this.deps.reactionManager.transition(this.deps.channel, chatId, messageId, 'processing', 'completed');
687
708
  }
688
- // Clear tool loop history for this session turn
689
- this.deps.toolLoopDetector?.clearSession(session.id);
690
- if (hooks) {
691
- await hooks.emit('agent_end', {
692
- event: 'agent_end',
693
- sessionId: session.id,
694
- data: { turns },
695
- timestamp: Date.now(),
696
- });
709
+ finally {
710
+ // Safety: always clear typing interval even if any unexpected error escapes.
711
+ this.deps.typingManager?.stop(chatId);
697
712
  }
698
713
  }
699
714
  }