@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.
- package/README.md +198 -27
- package/dist/auth/allow-from.d.ts +20 -0
- package/dist/auth/allow-from.d.ts.map +1 -1
- package/dist/auth/allow-from.js +30 -0
- package/dist/auth/allow-from.js.map +1 -1
- package/dist/channels/channel.d.ts +6 -0
- package/dist/channels/channel.d.ts.map +1 -1
- package/dist/channels/discord.d.ts.map +1 -1
- package/dist/channels/discord.js +31 -7
- package/dist/channels/discord.js.map +1 -1
- package/dist/channels/telegram.d.ts +1 -0
- package/dist/channels/telegram.d.ts.map +1 -1
- package/dist/channels/telegram.js +3 -0
- package/dist/channels/telegram.js.map +1 -1
- package/dist/commands/registry.d.ts.map +1 -1
- package/dist/commands/registry.js +40 -0
- package/dist/commands/registry.js.map +1 -1
- package/dist/core/agent-loop.d.ts.map +1 -1
- package/dist/core/agent-loop.js +456 -441
- package/dist/core/agent-loop.js.map +1 -1
- package/dist/core/message-queue.d.ts.map +1 -1
- package/dist/core/message-queue.js +19 -9
- package/dist/core/message-queue.js.map +1 -1
- package/dist/core/prompt.d.ts.map +1 -1
- package/dist/core/prompt.js +3 -1
- package/dist/core/prompt.js.map +1 -1
- package/dist/core/reactions.d.ts +5 -5
- package/dist/core/reactions.js +10 -10
- package/dist/core/reactions.js.map +1 -1
- package/dist/core/retry-queue.d.ts +2 -0
- package/dist/core/retry-queue.d.ts.map +1 -1
- package/dist/core/retry-queue.js +15 -0
- package/dist/core/retry-queue.js.map +1 -1
- package/dist/gateway/compaction.d.ts +1 -0
- package/dist/gateway/compaction.d.ts.map +1 -1
- package/dist/gateway/compaction.js +5 -1
- package/dist/gateway/compaction.js.map +1 -1
- package/dist/gateway/gateway.d.ts.map +1 -1
- package/dist/gateway/gateway.js +5 -3
- package/dist/gateway/gateway.js.map +1 -1
- package/dist/gateway/session.d.ts.map +1 -1
- package/dist/gateway/session.js +7 -0
- package/dist/gateway/session.js.map +1 -1
- package/dist/index.js +91 -16
- package/dist/index.js.map +1 -1
- package/dist/providers/failover.d.ts.map +1 -1
- package/dist/providers/failover.js +8 -7
- package/dist/providers/failover.js.map +1 -1
- package/dist/tools/message.d.ts +10 -0
- package/dist/tools/message.d.ts.map +1 -1
- package/dist/tools/message.js +61 -13
- package/dist/tools/message.js.map +1 -1
- package/dist/tools/system-tools.d.ts.map +1 -1
- package/dist/tools/system-tools.js +26 -1
- package/dist/tools/system-tools.js.map +1 -1
- package/docs/INSTALL.md +7 -4
- package/docs/channels.md +56 -115
- package/package.json +1 -1
package/dist/core/agent-loop.js
CHANGED
|
@@ -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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
220
|
+
yield `Invalid thinking level. Use one of: ${levels.join(', ')}`;
|
|
232
221
|
return;
|
|
233
222
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
console.warn(`[agent-loop] Failed to fetch image ${a.url}
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
this.deps.
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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:
|
|
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('
|
|
577
|
-
event: '
|
|
603
|
+
await hooks.emit('before_tool_call', {
|
|
604
|
+
event: 'before_tool_call',
|
|
578
605
|
sessionId: session.id,
|
|
579
|
-
data: { toolName: tc.name,
|
|
606
|
+
data: { toolName: tc.name, params: tc.input },
|
|
580
607
|
timestamp: Date.now(),
|
|
581
608
|
});
|
|
582
609
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
}
|