@townco/agent 0.1.88 → 0.1.101
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/dist/acp-server/adapter.d.ts +49 -0
- package/dist/acp-server/adapter.js +693 -5
- package/dist/acp-server/http.d.ts +7 -0
- package/dist/acp-server/http.js +53 -6
- package/dist/definition/index.d.ts +29 -0
- package/dist/definition/index.js +24 -0
- package/dist/runner/agent-runner.d.ts +16 -1
- package/dist/runner/agent-runner.js +2 -1
- package/dist/runner/e2b-sandbox-manager.d.ts +18 -0
- package/dist/runner/e2b-sandbox-manager.js +99 -0
- package/dist/runner/hooks/executor.d.ts +3 -1
- package/dist/runner/hooks/executor.js +21 -1
- package/dist/runner/hooks/predefined/compaction-tool.js +67 -2
- package/dist/runner/hooks/types.d.ts +5 -0
- package/dist/runner/index.d.ts +11 -0
- package/dist/runner/langchain/index.d.ts +10 -0
- package/dist/runner/langchain/index.js +227 -7
- package/dist/runner/langchain/model-factory.js +28 -1
- package/dist/runner/langchain/tools/artifacts.js +6 -3
- package/dist/runner/langchain/tools/e2b.d.ts +54 -0
- package/dist/runner/langchain/tools/e2b.js +360 -0
- package/dist/runner/langchain/tools/filesystem.js +63 -0
- package/dist/runner/langchain/tools/subagent.d.ts +8 -0
- package/dist/runner/langchain/tools/subagent.js +76 -4
- package/dist/runner/langchain/tools/web_search.d.ts +36 -14
- package/dist/runner/langchain/tools/web_search.js +33 -2
- package/dist/runner/session-context.d.ts +20 -0
- package/dist/runner/session-context.js +54 -0
- package/dist/runner/tools.d.ts +2 -2
- package/dist/runner/tools.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
|
@@ -2,6 +2,7 @@ import * as acp from "@agentclientprotocol/sdk";
|
|
|
2
2
|
import { context, trace } from "@opentelemetry/api";
|
|
3
3
|
import { createLogger } from "../logger.js";
|
|
4
4
|
import { getModelContextWindow, HookExecutor, loadHookCallback, } from "../runner/hooks";
|
|
5
|
+
import { getToolGroupChildren } from "../runner/langchain/index.js";
|
|
5
6
|
import { telemetry } from "../telemetry/index.js";
|
|
6
7
|
import { calculateContextSize, } from "../utils/context-size-calculator.js";
|
|
7
8
|
import { countToolResultTokens } from "../utils/token-counter.js";
|
|
@@ -142,13 +143,20 @@ export class AgentAcpAdapter {
|
|
|
142
143
|
/**
|
|
143
144
|
* Extract tool metadata from the agent definition for exposing to clients.
|
|
144
145
|
* This provides basic info about available tools without loading them fully.
|
|
146
|
+
* For tool groups (like town_e2b, filesystem), dynamically extracts children
|
|
147
|
+
* tools info from the actual tool factories.
|
|
145
148
|
*/
|
|
146
149
|
getToolsMetadata() {
|
|
147
150
|
const tools = this.agent.definition.tools ?? [];
|
|
148
151
|
return tools.map((tool) => {
|
|
149
152
|
if (typeof tool === "string") {
|
|
150
|
-
// Built-in tool -
|
|
151
|
-
|
|
153
|
+
// Built-in tool - dynamically get children if it's a tool group
|
|
154
|
+
const children = getToolGroupChildren(tool);
|
|
155
|
+
return {
|
|
156
|
+
name: tool,
|
|
157
|
+
description: `Built-in tool: ${tool}`,
|
|
158
|
+
...(children ? { children } : {}),
|
|
159
|
+
};
|
|
152
160
|
}
|
|
153
161
|
else if (tool.type === "direct") {
|
|
154
162
|
return {
|
|
@@ -159,9 +167,12 @@ export class AgentAcpAdapter {
|
|
|
159
167
|
};
|
|
160
168
|
}
|
|
161
169
|
else if (tool.type === "filesystem") {
|
|
170
|
+
// Filesystem is a tool group - dynamically get children
|
|
171
|
+
const children = getToolGroupChildren("filesystem");
|
|
162
172
|
return {
|
|
163
173
|
name: "filesystem",
|
|
164
174
|
description: "File system access tools",
|
|
175
|
+
...(children ? { children } : {}),
|
|
165
176
|
};
|
|
166
177
|
}
|
|
167
178
|
else if (tool.type === "custom") {
|
|
@@ -215,6 +226,418 @@ export class AgentAcpAdapter {
|
|
|
215
226
|
}
|
|
216
227
|
return [];
|
|
217
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Extract citation sources from tool output.
|
|
231
|
+
* Supports WebSearch (Exa results), WebFetch, library/document retrieval tools,
|
|
232
|
+
* and generic URL extraction from MCP tools.
|
|
233
|
+
*/
|
|
234
|
+
extractSourcesFromToolOutput(toolName, rawOutput, toolCallId, session) {
|
|
235
|
+
const sources = [];
|
|
236
|
+
// Log all tool names to debug source extraction
|
|
237
|
+
logger.info("extractSourcesFromToolOutput called", {
|
|
238
|
+
toolName,
|
|
239
|
+
hasRawOutput: !!rawOutput,
|
|
240
|
+
rawOutputType: typeof rawOutput,
|
|
241
|
+
isLibraryTool: toolName.startsWith("library__"),
|
|
242
|
+
});
|
|
243
|
+
if (!rawOutput || typeof rawOutput !== "object") {
|
|
244
|
+
return sources;
|
|
245
|
+
}
|
|
246
|
+
const output = rawOutput;
|
|
247
|
+
// Handle WebSearch (Exa) results
|
|
248
|
+
if (toolName === "WebSearch" || toolName === "web_search") {
|
|
249
|
+
// Check for formatted results with citation IDs first
|
|
250
|
+
const formattedResults = output.formattedForCitation;
|
|
251
|
+
if (Array.isArray(formattedResults)) {
|
|
252
|
+
for (const result of formattedResults) {
|
|
253
|
+
if (result &&
|
|
254
|
+
typeof result === "object" &&
|
|
255
|
+
"url" in result &&
|
|
256
|
+
typeof result.url === "string") {
|
|
257
|
+
// Use the citationId from the tool output if available
|
|
258
|
+
const citationId = typeof result.citationId === "number"
|
|
259
|
+
? String(result.citationId)
|
|
260
|
+
: (session.sourceCounter++, String(session.sourceCounter));
|
|
261
|
+
const url = result.url;
|
|
262
|
+
const title = typeof result.title === "string" ? result.title : "Untitled";
|
|
263
|
+
const snippet = typeof result.text === "string"
|
|
264
|
+
? result.text.slice(0, 200)
|
|
265
|
+
: undefined;
|
|
266
|
+
sources.push({
|
|
267
|
+
id: citationId,
|
|
268
|
+
url,
|
|
269
|
+
title,
|
|
270
|
+
snippet,
|
|
271
|
+
favicon: this.getFaviconUrl(url),
|
|
272
|
+
toolCallId,
|
|
273
|
+
sourceName: this.getSourceName(url),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
// Fallback to raw results (backwards compatibility)
|
|
280
|
+
const results = output.results;
|
|
281
|
+
if (Array.isArray(results)) {
|
|
282
|
+
for (const result of results) {
|
|
283
|
+
if (result &&
|
|
284
|
+
typeof result === "object" &&
|
|
285
|
+
"url" in result &&
|
|
286
|
+
typeof result.url === "string") {
|
|
287
|
+
session.sourceCounter++;
|
|
288
|
+
const url = result.url;
|
|
289
|
+
const title = typeof result.title === "string" ? result.title : "Untitled";
|
|
290
|
+
const snippet = typeof result.text === "string"
|
|
291
|
+
? result.text.slice(0, 200)
|
|
292
|
+
: undefined;
|
|
293
|
+
sources.push({
|
|
294
|
+
id: String(session.sourceCounter),
|
|
295
|
+
url,
|
|
296
|
+
title,
|
|
297
|
+
snippet,
|
|
298
|
+
favicon: this.getFaviconUrl(url),
|
|
299
|
+
toolCallId,
|
|
300
|
+
sourceName: this.getSourceName(url),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Handle WebFetch - extract URL from rawInput if available
|
|
308
|
+
if (toolName === "WebFetch" || toolName === "web_fetch") {
|
|
309
|
+
// WebFetch might have URL in rawInput, or we can try to extract from output
|
|
310
|
+
// For now, we'll extract URLs from the output text if present
|
|
311
|
+
const content = output.content;
|
|
312
|
+
if (typeof content === "string") {
|
|
313
|
+
const urls = this.extractUrlsFromText(content);
|
|
314
|
+
for (const url of urls.slice(0, 3)) {
|
|
315
|
+
// Limit to 3 URLs
|
|
316
|
+
session.sourceCounter++;
|
|
317
|
+
sources.push({
|
|
318
|
+
id: String(session.sourceCounter),
|
|
319
|
+
url,
|
|
320
|
+
title: url, // Use URL as title for fetched pages
|
|
321
|
+
toolCallId,
|
|
322
|
+
favicon: this.getFaviconUrl(url),
|
|
323
|
+
sourceName: this.getSourceName(url),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Handle library/document retrieval tools (e.g., library__get_document, library__search_keyword)
|
|
329
|
+
// These return structured data with document_url, title, summary, document_id, etc.
|
|
330
|
+
const isLibraryTool = toolName.startsWith("library__") ||
|
|
331
|
+
toolName.includes("get_document") ||
|
|
332
|
+
toolName.includes("retrieve_document");
|
|
333
|
+
logger.info("Library tool check", {
|
|
334
|
+
toolName,
|
|
335
|
+
isLibraryTool,
|
|
336
|
+
startsWithLibrary: toolName.startsWith("library__"),
|
|
337
|
+
});
|
|
338
|
+
if (isLibraryTool) {
|
|
339
|
+
logger.info("Processing library tool output for sources", {
|
|
340
|
+
toolName,
|
|
341
|
+
hasContent: "content" in output,
|
|
342
|
+
contentType: typeof output.content,
|
|
343
|
+
outputKeys: Object.keys(output).slice(0, 10),
|
|
344
|
+
});
|
|
345
|
+
// Content may be:
|
|
346
|
+
// 1. Nested in output.content as an object
|
|
347
|
+
// 2. A JSON string in output.content (MCP tools wrap results as JSON strings)
|
|
348
|
+
// 3. Directly in output
|
|
349
|
+
let outputContent;
|
|
350
|
+
if (typeof output.content === "object" && output.content !== null) {
|
|
351
|
+
// Content is already an object
|
|
352
|
+
outputContent = output.content;
|
|
353
|
+
}
|
|
354
|
+
else if (typeof output.content === "string") {
|
|
355
|
+
// Content is a JSON string - parse it (common for MCP tools)
|
|
356
|
+
try {
|
|
357
|
+
const parsed = JSON.parse(output.content);
|
|
358
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
359
|
+
outputContent = parsed;
|
|
360
|
+
logger.info("Parsed library tool JSON content", {
|
|
361
|
+
toolName,
|
|
362
|
+
parsedKeys: Object.keys(outputContent).slice(0, 10),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
outputContent = output;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// Not valid JSON, use output directly
|
|
371
|
+
outputContent = output;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
outputContent = output;
|
|
376
|
+
}
|
|
377
|
+
// Helper to extract a single document source
|
|
378
|
+
const extractDocSource = (doc) => {
|
|
379
|
+
// Extract document URL (may be document_url, url, or source_url)
|
|
380
|
+
const docUrl = typeof doc.document_url === "string"
|
|
381
|
+
? doc.document_url
|
|
382
|
+
: typeof doc.url === "string"
|
|
383
|
+
? doc.url
|
|
384
|
+
: typeof doc.source_url === "string"
|
|
385
|
+
? doc.source_url
|
|
386
|
+
: null;
|
|
387
|
+
// Extract title
|
|
388
|
+
const docTitle = typeof doc.title === "string" ? doc.title : null;
|
|
389
|
+
// Extract document_id for use as citation ID
|
|
390
|
+
const docId = typeof doc.document_id === "number"
|
|
391
|
+
? String(doc.document_id)
|
|
392
|
+
: typeof doc.document_id === "string"
|
|
393
|
+
? doc.document_id
|
|
394
|
+
: null;
|
|
395
|
+
// Only add as source if we have at least a URL or title
|
|
396
|
+
if (!docUrl && !docTitle)
|
|
397
|
+
return null;
|
|
398
|
+
// Use document_id as the citation ID if available, otherwise use counter
|
|
399
|
+
const citationId = docId || (session.sourceCounter++, String(session.sourceCounter));
|
|
400
|
+
// Extract snippet from summary or content
|
|
401
|
+
let snippet;
|
|
402
|
+
if (typeof doc.summary === "string") {
|
|
403
|
+
snippet = doc.summary.slice(0, 200);
|
|
404
|
+
}
|
|
405
|
+
else if (typeof doc.content === "string") {
|
|
406
|
+
snippet = doc.content.slice(0, 200);
|
|
407
|
+
}
|
|
408
|
+
// Extract source name from the source field or derive from URL
|
|
409
|
+
let sourceName;
|
|
410
|
+
if (typeof doc.source === "string") {
|
|
411
|
+
sourceName = doc.source
|
|
412
|
+
.split("_")
|
|
413
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
414
|
+
.join(" ");
|
|
415
|
+
}
|
|
416
|
+
else if (docUrl) {
|
|
417
|
+
sourceName = this.getSourceName(docUrl);
|
|
418
|
+
}
|
|
419
|
+
const citationSource = {
|
|
420
|
+
id: citationId,
|
|
421
|
+
url: docUrl || "",
|
|
422
|
+
title: docTitle || "Document",
|
|
423
|
+
toolCallId,
|
|
424
|
+
};
|
|
425
|
+
if (snippet)
|
|
426
|
+
citationSource.snippet = snippet;
|
|
427
|
+
if (sourceName)
|
|
428
|
+
citationSource.sourceName = sourceName;
|
|
429
|
+
if (docUrl)
|
|
430
|
+
citationSource.favicon = this.getFaviconUrl(docUrl);
|
|
431
|
+
logger.debug("Extracted library document source", {
|
|
432
|
+
citationId,
|
|
433
|
+
docId,
|
|
434
|
+
docTitle,
|
|
435
|
+
docUrl: docUrl?.slice(0, 50),
|
|
436
|
+
toolName,
|
|
437
|
+
});
|
|
438
|
+
return citationSource;
|
|
439
|
+
};
|
|
440
|
+
// Check if this is a search results array (library__search_keyword)
|
|
441
|
+
const results = outputContent.results ?? outputContent.documents;
|
|
442
|
+
if (Array.isArray(results)) {
|
|
443
|
+
// Handle array of search results
|
|
444
|
+
logger.debug("Processing library search results array", {
|
|
445
|
+
resultCount: results.length,
|
|
446
|
+
toolName,
|
|
447
|
+
});
|
|
448
|
+
for (const result of results) {
|
|
449
|
+
if (result && typeof result === "object") {
|
|
450
|
+
const source = extractDocSource(result);
|
|
451
|
+
if (source)
|
|
452
|
+
sources.push(source);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
// Handle single document (library__get_document)
|
|
458
|
+
logger.debug("Processing single library document", {
|
|
459
|
+
hasDocumentId: "document_id" in outputContent,
|
|
460
|
+
hasTitle: "title" in outputContent,
|
|
461
|
+
hasDocUrl: "document_url" in outputContent ||
|
|
462
|
+
"url" in outputContent ||
|
|
463
|
+
"source_url" in outputContent,
|
|
464
|
+
contentKeys: Object.keys(outputContent).slice(0, 10),
|
|
465
|
+
toolName,
|
|
466
|
+
});
|
|
467
|
+
const source = extractDocSource(outputContent);
|
|
468
|
+
if (source)
|
|
469
|
+
sources.push(source);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// Handle MCP tools - try to extract URLs from any output
|
|
473
|
+
if (sources.length === 0 && output.content) {
|
|
474
|
+
const content = output.content;
|
|
475
|
+
if (typeof content === "string") {
|
|
476
|
+
logger.info("Fallback URL extraction running (no sources from tool handler)", {
|
|
477
|
+
toolName,
|
|
478
|
+
contentLength: content.length,
|
|
479
|
+
});
|
|
480
|
+
const urls = this.extractUrlsFromText(content);
|
|
481
|
+
for (const url of urls.slice(0, 5)) {
|
|
482
|
+
// Limit to 5 URLs for generic extraction
|
|
483
|
+
session.sourceCounter++;
|
|
484
|
+
logger.info("Extracted URL via fallback", {
|
|
485
|
+
id: String(session.sourceCounter),
|
|
486
|
+
url: url.slice(0, 80),
|
|
487
|
+
});
|
|
488
|
+
sources.push({
|
|
489
|
+
id: String(session.sourceCounter),
|
|
490
|
+
url,
|
|
491
|
+
title: url,
|
|
492
|
+
toolCallId,
|
|
493
|
+
favicon: this.getFaviconUrl(url),
|
|
494
|
+
sourceName: this.getSourceName(url),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return sources;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Extract citation sources from a subagent result.
|
|
503
|
+
* The sources are already re-numbered by the runner with unique IDs (1001+, 2001+, etc.)
|
|
504
|
+
* to avoid conflicts with parent agent sources.
|
|
505
|
+
*/
|
|
506
|
+
extractSubagentSources(rawOutput, toolCallId) {
|
|
507
|
+
if (!rawOutput) {
|
|
508
|
+
logger.debug("extractSubagentSources: no rawOutput");
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
let output;
|
|
512
|
+
// Handle both string (JSON-serialized) and object formats
|
|
513
|
+
if (typeof rawOutput === "string") {
|
|
514
|
+
try {
|
|
515
|
+
const parsed = JSON.parse(rawOutput);
|
|
516
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
517
|
+
logger.debug("extractSubagentSources: string parsed to non-object");
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
output = parsed;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
logger.debug("extractSubagentSources: failed to parse string as JSON");
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else if (typeof rawOutput === "object") {
|
|
528
|
+
output = rawOutput;
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
logger.debug("extractSubagentSources: rawOutput is neither string nor object", {
|
|
532
|
+
type: typeof rawOutput,
|
|
533
|
+
});
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
logger.info("extractSubagentSources: initial output structure", {
|
|
537
|
+
outputKeys: Object.keys(output).slice(0, 10),
|
|
538
|
+
hasText: "text" in output,
|
|
539
|
+
hasSources: "sources" in output,
|
|
540
|
+
hasContent: "content" in output,
|
|
541
|
+
contentType: typeof output.content,
|
|
542
|
+
});
|
|
543
|
+
// Check if output is wrapped in { content: "..." } format
|
|
544
|
+
if (typeof output.content === "string" &&
|
|
545
|
+
!("text" in output) &&
|
|
546
|
+
!("sources" in output)) {
|
|
547
|
+
try {
|
|
548
|
+
const parsed = JSON.parse(output.content);
|
|
549
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
550
|
+
output = parsed;
|
|
551
|
+
logger.info("extractSubagentSources: unwrapped content", {
|
|
552
|
+
newOutputKeys: Object.keys(output).slice(0, 10),
|
|
553
|
+
hasText: "text" in output,
|
|
554
|
+
hasSources: "sources" in output,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
logger.debug("extractSubagentSources: content is not JSON");
|
|
560
|
+
// Not JSON, continue with original output
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Check if this looks like a SubagentResult with sources
|
|
564
|
+
if (typeof output.text !== "string" || !Array.isArray(output.sources)) {
|
|
565
|
+
logger.info("extractSubagentSources: not a SubagentResult", {
|
|
566
|
+
textType: typeof output.text,
|
|
567
|
+
sourcesIsArray: Array.isArray(output.sources),
|
|
568
|
+
outputKeys: Object.keys(output).slice(0, 10),
|
|
569
|
+
});
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
const subagentSources = output.sources;
|
|
573
|
+
if (subagentSources.length === 0) {
|
|
574
|
+
return { text: output.text, sources: [] };
|
|
575
|
+
}
|
|
576
|
+
// Sources are already numbered uniquely by the runner
|
|
577
|
+
// Just convert them to CitationSource format with parent's toolCallId
|
|
578
|
+
const sources = subagentSources.map((source) => {
|
|
579
|
+
const citationSource = {
|
|
580
|
+
id: source.id,
|
|
581
|
+
url: source.url,
|
|
582
|
+
title: source.title,
|
|
583
|
+
toolCallId, // Use parent's toolCallId for association
|
|
584
|
+
};
|
|
585
|
+
if (source.snippet)
|
|
586
|
+
citationSource.snippet = source.snippet;
|
|
587
|
+
if (source.favicon)
|
|
588
|
+
citationSource.favicon = source.favicon;
|
|
589
|
+
if (source.sourceName)
|
|
590
|
+
citationSource.sourceName = source.sourceName;
|
|
591
|
+
return citationSource;
|
|
592
|
+
});
|
|
593
|
+
return { text: output.text, sources };
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Helper to derive favicon URL from a domain
|
|
597
|
+
*/
|
|
598
|
+
getFaviconUrl(url) {
|
|
599
|
+
try {
|
|
600
|
+
const domain = new URL(url).hostname;
|
|
601
|
+
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return "";
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Helper to extract domain name from URL
|
|
609
|
+
*/
|
|
610
|
+
getSourceName(url) {
|
|
611
|
+
try {
|
|
612
|
+
const hostname = new URL(url).hostname;
|
|
613
|
+
// Remove www. prefix and capitalize first letter
|
|
614
|
+
const name = hostname.replace(/^www\./, "").split(".")[0] || hostname;
|
|
615
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return "Unknown";
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Helper to extract URLs from text content
|
|
623
|
+
*/
|
|
624
|
+
extractUrlsFromText(text) {
|
|
625
|
+
const urlRegex = /https?:\/\/[^\s<>"')\]]+/g;
|
|
626
|
+
const matches = text.match(urlRegex);
|
|
627
|
+
if (!matches)
|
|
628
|
+
return [];
|
|
629
|
+
// Deduplicate and filter out common non-page URLs
|
|
630
|
+
const seen = new Set();
|
|
631
|
+
return matches.filter((url) => {
|
|
632
|
+
if (seen.has(url))
|
|
633
|
+
return false;
|
|
634
|
+
seen.add(url);
|
|
635
|
+
// Filter out common non-content URLs
|
|
636
|
+
if (url.includes("favicon") || url.includes("logo"))
|
|
637
|
+
return false;
|
|
638
|
+
return true;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
218
641
|
/**
|
|
219
642
|
* Helper to save session to disk
|
|
220
643
|
* Call this after any modification to session.messages or session.context
|
|
@@ -274,6 +697,9 @@ export class AgentAcpAdapter {
|
|
|
274
697
|
...(toolsMetadata.length > 0 ? { tools: toolsMetadata } : {}),
|
|
275
698
|
...(mcpsMetadata.length > 0 ? { mcps: mcpsMetadata } : {}),
|
|
276
699
|
...(subagentsMetadata.length > 0 ? { subagents: subagentsMetadata } : {}),
|
|
700
|
+
...(this.agent.definition.promptParameters
|
|
701
|
+
? { promptParameters: this.agent.definition.promptParameters }
|
|
702
|
+
: {}),
|
|
277
703
|
};
|
|
278
704
|
return response;
|
|
279
705
|
}
|
|
@@ -290,6 +716,9 @@ export class AgentAcpAdapter {
|
|
|
290
716
|
context: [],
|
|
291
717
|
requestParams: params,
|
|
292
718
|
configOverrides,
|
|
719
|
+
isCancelled: false,
|
|
720
|
+
sourceCounter: 0,
|
|
721
|
+
sources: [],
|
|
293
722
|
};
|
|
294
723
|
this.sessions.set(sessionId, sessionData);
|
|
295
724
|
// Note: Initial message is sent by the HTTP transport when SSE connection is established
|
|
@@ -332,12 +761,45 @@ export class AgentAcpAdapter {
|
|
|
332
761
|
if (!storedSession) {
|
|
333
762
|
throw new Error(`Session ${params.sessionId} not found`);
|
|
334
763
|
}
|
|
764
|
+
// Repair any incomplete tool calls in the session (e.g., from cancelled turns)
|
|
765
|
+
// This is required because Claude's API requires every tool_use to have a tool_result
|
|
766
|
+
let sessionRepaired = false;
|
|
767
|
+
for (const msg of storedSession.messages) {
|
|
768
|
+
if (msg.role === "assistant") {
|
|
769
|
+
for (const block of msg.content) {
|
|
770
|
+
if (block.type === "tool_call") {
|
|
771
|
+
const toolCall = block;
|
|
772
|
+
if ((toolCall.status === "pending" ||
|
|
773
|
+
toolCall.status === "in_progress") &&
|
|
774
|
+
!toolCall.rawOutput) {
|
|
775
|
+
toolCall.status = "failed";
|
|
776
|
+
toolCall.rawOutput = { content: "[Cancelled]" };
|
|
777
|
+
toolCall.error = "Tool execution was interrupted";
|
|
778
|
+
sessionRepaired = true;
|
|
779
|
+
logger.info("Repaired incomplete tool call in loaded session", {
|
|
780
|
+
sessionId: params.sessionId,
|
|
781
|
+
toolCallId: toolCall.id,
|
|
782
|
+
toolName: toolCall.title,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Save repaired session if changes were made
|
|
790
|
+
if (sessionRepaired && this.storage) {
|
|
791
|
+
await this.storage.saveSession(params.sessionId, storedSession.messages, storedSession.context);
|
|
792
|
+
logger.info("Saved repaired session", { sessionId: params.sessionId });
|
|
793
|
+
}
|
|
335
794
|
// Restore session in active sessions map
|
|
336
795
|
this.sessions.set(params.sessionId, {
|
|
337
796
|
pendingPrompt: null,
|
|
338
797
|
messages: storedSession.messages,
|
|
339
798
|
context: storedSession.context,
|
|
340
799
|
requestParams: { cwd: process.cwd(), mcpServers: [] },
|
|
800
|
+
isCancelled: false,
|
|
801
|
+
sourceCounter: 0,
|
|
802
|
+
sources: [],
|
|
341
803
|
});
|
|
342
804
|
// Replay conversation history to client with ordered content blocks
|
|
343
805
|
logger.info(`Replaying ${storedSession.messages.length} messages for session ${params.sessionId}`);
|
|
@@ -428,6 +890,27 @@ export class AgentAcpAdapter {
|
|
|
428
890
|
sessionId: params.sessionId,
|
|
429
891
|
update: outputUpdate,
|
|
430
892
|
});
|
|
893
|
+
// Extract sources from replayed tool output
|
|
894
|
+
const session = this.sessions.get(params.sessionId);
|
|
895
|
+
if (session) {
|
|
896
|
+
const extractedSources = this.extractSourcesFromToolOutput(block.title, block.rawOutput, block.id, session);
|
|
897
|
+
if (extractedSources.length > 0) {
|
|
898
|
+
session.sources.push(...extractedSources);
|
|
899
|
+
// Emit sources notification to client during replay
|
|
900
|
+
this.connection.sessionUpdate({
|
|
901
|
+
sessionId: params.sessionId,
|
|
902
|
+
update: {
|
|
903
|
+
sessionUpdate: "sources",
|
|
904
|
+
sources: extractedSources,
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
logger.info("Extracted sources during session replay", {
|
|
908
|
+
toolCallId: block.id,
|
|
909
|
+
toolName: block.title,
|
|
910
|
+
sourcesCount: extractedSources.length,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
431
914
|
}
|
|
432
915
|
// Also emit tool_call_update with final status for consistency
|
|
433
916
|
this.connection.sessionUpdate({
|
|
@@ -512,11 +995,16 @@ export class AgentAcpAdapter {
|
|
|
512
995
|
messages: [],
|
|
513
996
|
context: [],
|
|
514
997
|
requestParams: { cwd: process.cwd(), mcpServers: [] },
|
|
998
|
+
isCancelled: false,
|
|
999
|
+
sourceCounter: 0,
|
|
1000
|
+
sources: [],
|
|
515
1001
|
};
|
|
516
1002
|
this.sessions.set(params.sessionId, session);
|
|
517
1003
|
}
|
|
518
1004
|
session.pendingPrompt?.abort();
|
|
519
1005
|
session.pendingPrompt = new AbortController();
|
|
1006
|
+
// Reset cancelled flag for new prompt
|
|
1007
|
+
session.isCancelled = false;
|
|
520
1008
|
// Reset tool overhead for new turn (will be set by harness)
|
|
521
1009
|
this.currentToolOverheadTokens = 0;
|
|
522
1010
|
this.currentMcpOverheadTokens = 0;
|
|
@@ -622,6 +1110,52 @@ export class AgentAcpAdapter {
|
|
|
622
1110
|
pendingText = "";
|
|
623
1111
|
}
|
|
624
1112
|
};
|
|
1113
|
+
// Helper to save cancelled message to session
|
|
1114
|
+
const saveCancelledMessage = async () => {
|
|
1115
|
+
if (this.noSession)
|
|
1116
|
+
return;
|
|
1117
|
+
// Flush any pending text
|
|
1118
|
+
flushPendingText();
|
|
1119
|
+
// Find and complete any incomplete tool calls with cancelled status
|
|
1120
|
+
// This is required because Claude's API requires every tool_use to have a tool_result
|
|
1121
|
+
for (const block of contentBlocks) {
|
|
1122
|
+
if (block.type === "tool_call") {
|
|
1123
|
+
const toolCall = block;
|
|
1124
|
+
if (toolCall.status === "pending" ||
|
|
1125
|
+
toolCall.status === "in_progress") {
|
|
1126
|
+
toolCall.status = "failed";
|
|
1127
|
+
toolCall.rawOutput = { content: "[Cancelled]" };
|
|
1128
|
+
toolCall.error = "Tool execution cancelled by user";
|
|
1129
|
+
logger.debug("Marked incomplete tool call as cancelled", {
|
|
1130
|
+
toolCallId: toolCall.id,
|
|
1131
|
+
toolName: toolCall.title,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Add "[Cancelled]" text block
|
|
1137
|
+
contentBlocks.push({ type: "text", text: "\n\n[Cancelled]" });
|
|
1138
|
+
if (contentBlocks.length > 0) {
|
|
1139
|
+
const cancelledMessage = {
|
|
1140
|
+
role: "assistant",
|
|
1141
|
+
content: contentBlocks,
|
|
1142
|
+
timestamp: new Date().toISOString(),
|
|
1143
|
+
};
|
|
1144
|
+
// Check if we already have a partial assistant message
|
|
1145
|
+
const lastMessage = session.messages[session.messages.length - 1];
|
|
1146
|
+
if (lastMessage && lastMessage.role === "assistant") {
|
|
1147
|
+
session.messages[session.messages.length - 1] = cancelledMessage;
|
|
1148
|
+
}
|
|
1149
|
+
else {
|
|
1150
|
+
session.messages.push(cancelledMessage);
|
|
1151
|
+
}
|
|
1152
|
+
await this.saveSessionToDisk(params.sessionId, session);
|
|
1153
|
+
logger.info("Saved cancelled message to session", {
|
|
1154
|
+
sessionId: params.sessionId,
|
|
1155
|
+
contentBlocks: contentBlocks.length,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
625
1159
|
// Declare agentResponse and turnTokenUsage outside try block so they're accessible after catch
|
|
626
1160
|
let _agentResponse;
|
|
627
1161
|
// Track accumulated token usage during the turn
|
|
@@ -670,6 +1204,8 @@ export class AgentAcpAdapter {
|
|
|
670
1204
|
...(this.agentDir ? { agentDir: this.agentDir } : {}),
|
|
671
1205
|
// Pass resolved context messages to agent
|
|
672
1206
|
contextMessages,
|
|
1207
|
+
// Pass abort signal for cancellation
|
|
1208
|
+
abortSignal: session.pendingPrompt?.signal,
|
|
673
1209
|
};
|
|
674
1210
|
// Only add sessionMeta if it's defined
|
|
675
1211
|
if (session.requestParams._meta) {
|
|
@@ -679,12 +1215,25 @@ export class AgentAcpAdapter {
|
|
|
679
1215
|
if (session.configOverrides) {
|
|
680
1216
|
invokeParams.configOverrides = session.configOverrides;
|
|
681
1217
|
}
|
|
1218
|
+
// Extract and pass promptParameters from the prompt request _meta
|
|
1219
|
+
const promptParameters = params._meta?.promptParameters;
|
|
1220
|
+
if (promptParameters) {
|
|
1221
|
+
invokeParams.promptParameters = promptParameters;
|
|
1222
|
+
}
|
|
682
1223
|
const generator = this.agent.invoke(invokeParams);
|
|
683
1224
|
// Track the invocation span for parenting hook spans
|
|
684
1225
|
let invocationSpan = null;
|
|
685
1226
|
// Manually iterate to capture the return value
|
|
686
1227
|
let iterResult = await generator.next();
|
|
687
1228
|
while (!iterResult.done) {
|
|
1229
|
+
// Check for cancellation at start of each iteration
|
|
1230
|
+
if (session.pendingPrompt?.signal.aborted) {
|
|
1231
|
+
logger.info("Cancellation detected in generator loop, stopping iteration", {
|
|
1232
|
+
sessionId: params.sessionId,
|
|
1233
|
+
});
|
|
1234
|
+
await saveCancelledMessage();
|
|
1235
|
+
return { stopReason: "cancelled" };
|
|
1236
|
+
}
|
|
688
1237
|
const msg = iterResult.value;
|
|
689
1238
|
// Capture the invocation span so we can use it for parenting hook spans
|
|
690
1239
|
if ("sessionUpdate" in msg &&
|
|
@@ -993,7 +1542,7 @@ export class AgentAcpAdapter {
|
|
|
993
1542
|
},
|
|
994
1543
|
});
|
|
995
1544
|
};
|
|
996
|
-
const hookExecutor = new HookExecutor(hooks, this.agent.definition.model, (callbackRef) => loadHookCallback(callbackRef, this.agentDir), sendHookNotification);
|
|
1545
|
+
const hookExecutor = new HookExecutor(hooks, this.agent.definition.model, (callbackRef) => loadHookCallback(callbackRef, this.agentDir), sendHookNotification, this.agent.definition);
|
|
997
1546
|
const hookResult = await hookExecutor.executeToolResponseHooks({
|
|
998
1547
|
messages: session.messages,
|
|
999
1548
|
context: session.context,
|
|
@@ -1072,6 +1621,60 @@ export class AgentAcpAdapter {
|
|
|
1072
1621
|
if (rawOutput) {
|
|
1073
1622
|
toolCallBlock.rawOutput = rawOutput;
|
|
1074
1623
|
}
|
|
1624
|
+
// Handle subagent tool outputs - extract citation sources
|
|
1625
|
+
// Subagent tools return { text: string, sources: CitationSource[] }
|
|
1626
|
+
// Sources are already re-numbered by the runner with unique IDs (1001+, 2001+, etc.)
|
|
1627
|
+
const rawOutputForLog = rawOutput;
|
|
1628
|
+
logger.debug("Checking for subagent sources in tool output", {
|
|
1629
|
+
toolCallId: outputMsg.toolCallId,
|
|
1630
|
+
toolName: toolCallBlock.title,
|
|
1631
|
+
rawOutputType: typeof rawOutputForLog,
|
|
1632
|
+
rawOutputPreview: typeof rawOutputForLog === "string"
|
|
1633
|
+
? rawOutputForLog.slice(0, 200)
|
|
1634
|
+
: typeof rawOutputForLog === "object" && rawOutputForLog
|
|
1635
|
+
? JSON.stringify(rawOutputForLog).slice(0, 200)
|
|
1636
|
+
: String(rawOutputForLog),
|
|
1637
|
+
});
|
|
1638
|
+
const subagentResult = this.extractSubagentSources(rawOutput, outputMsg.toolCallId);
|
|
1639
|
+
if (subagentResult && subagentResult.sources.length > 0) {
|
|
1640
|
+
// Add sources to session (already uniquely numbered)
|
|
1641
|
+
session.sources.push(...subagentResult.sources);
|
|
1642
|
+
// Emit sources notification to client
|
|
1643
|
+
this.connection.sessionUpdate({
|
|
1644
|
+
sessionId: params.sessionId,
|
|
1645
|
+
update: {
|
|
1646
|
+
sessionUpdate: "sources",
|
|
1647
|
+
sources: subagentResult.sources,
|
|
1648
|
+
},
|
|
1649
|
+
});
|
|
1650
|
+
logger.info("Extracted citation sources from subagent", {
|
|
1651
|
+
toolCallId: outputMsg.toolCallId,
|
|
1652
|
+
sourcesCount: subagentResult.sources.length,
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
// Extract citation sources from tool output (WebSearch, WebFetch, MCP tools)
|
|
1656
|
+
// Skip if we already extracted sources from a subagent result (with actual sources)
|
|
1657
|
+
const subagentHadSources = subagentResult && subagentResult.sources.length > 0;
|
|
1658
|
+
if (!subagentHadSources) {
|
|
1659
|
+
const extractedSources = this.extractSourcesFromToolOutput(toolCallBlock.title, rawOutput, outputMsg.toolCallId, session);
|
|
1660
|
+
if (extractedSources.length > 0) {
|
|
1661
|
+
// Add to session sources
|
|
1662
|
+
session.sources.push(...extractedSources);
|
|
1663
|
+
// Emit sources notification to client
|
|
1664
|
+
this.connection.sessionUpdate({
|
|
1665
|
+
sessionId: params.sessionId,
|
|
1666
|
+
update: {
|
|
1667
|
+
sessionUpdate: "sources",
|
|
1668
|
+
sources: extractedSources,
|
|
1669
|
+
},
|
|
1670
|
+
});
|
|
1671
|
+
logger.info("Extracted citation sources from tool output", {
|
|
1672
|
+
toolCallId: outputMsg.toolCallId,
|
|
1673
|
+
toolName: toolCallBlock.title,
|
|
1674
|
+
sourcesCount: extractedSources.length,
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1075
1678
|
// Store truncation warning if present (for UI display)
|
|
1076
1679
|
if (truncationWarning) {
|
|
1077
1680
|
if (!toolCallBlock._meta) {
|
|
@@ -1271,6 +1874,7 @@ export class AgentAcpAdapter {
|
|
|
1271
1874
|
}
|
|
1272
1875
|
catch (err) {
|
|
1273
1876
|
if (session.pendingPrompt.signal.aborted) {
|
|
1877
|
+
await saveCancelledMessage();
|
|
1274
1878
|
return { stopReason: "cancelled" };
|
|
1275
1879
|
}
|
|
1276
1880
|
throw err;
|
|
@@ -1396,7 +2000,7 @@ export class AgentAcpAdapter {
|
|
|
1396
2000
|
},
|
|
1397
2001
|
});
|
|
1398
2002
|
};
|
|
1399
|
-
const hookExecutor = new HookExecutor(hooks, this.agent.definition.model, (callbackRef) => loadHookCallback(callbackRef, this.agentDir), sendHookNotification);
|
|
2003
|
+
const hookExecutor = new HookExecutor(hooks, this.agent.definition.model, (callbackRef) => loadHookCallback(callbackRef, this.agentDir), sendHookNotification, this.agent.definition);
|
|
1400
2004
|
// Create read-only session view for hooks
|
|
1401
2005
|
const readonlySession = {
|
|
1402
2006
|
messages: session.messages,
|
|
@@ -1441,6 +2045,90 @@ export class AgentAcpAdapter {
|
|
|
1441
2045
|
return hookResult.newContextEntries;
|
|
1442
2046
|
}
|
|
1443
2047
|
async cancel(params) {
|
|
1444
|
-
|
|
2048
|
+
logger.info("Cancel requested", { sessionId: params.sessionId });
|
|
2049
|
+
const session = this.sessions.get(params.sessionId);
|
|
2050
|
+
if (session) {
|
|
2051
|
+
// Mark session as cancelled so tools can check this flag
|
|
2052
|
+
session.isCancelled = true;
|
|
2053
|
+
if (session.pendingPrompt) {
|
|
2054
|
+
logger.info("Aborting pending prompt", { sessionId: params.sessionId });
|
|
2055
|
+
session.pendingPrompt.abort();
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
else {
|
|
2059
|
+
logger.warn("No session found to cancel", {
|
|
2060
|
+
sessionId: params.sessionId,
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Edit and resend a message from a specific point in the conversation.
|
|
2066
|
+
* This truncates the session history to the specified user message
|
|
2067
|
+
* and then prompts with the new content.
|
|
2068
|
+
*
|
|
2069
|
+
* @param sessionId - The session to edit
|
|
2070
|
+
* @param userMessageIndex - The index of which user message to edit (0-based, counting only user messages)
|
|
2071
|
+
* @param newPrompt - The new prompt content to send
|
|
2072
|
+
* @returns PromptResponse from the agent
|
|
2073
|
+
*/
|
|
2074
|
+
async editAndResend(sessionId, userMessageIndex, newPrompt) {
|
|
2075
|
+
const session = this.sessions.get(sessionId);
|
|
2076
|
+
if (!session) {
|
|
2077
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
2078
|
+
}
|
|
2079
|
+
logger.info("Edit and resend requested", {
|
|
2080
|
+
sessionId,
|
|
2081
|
+
userMessageIndex,
|
|
2082
|
+
currentMessageCount: session.messages.length,
|
|
2083
|
+
currentContextCount: session.context.length,
|
|
2084
|
+
});
|
|
2085
|
+
// Find the Nth user message (0-indexed)
|
|
2086
|
+
let userMessageCount = 0;
|
|
2087
|
+
let targetMessageIndex = -1;
|
|
2088
|
+
for (let i = 0; i < session.messages.length; i++) {
|
|
2089
|
+
if (session.messages[i]?.role === "user") {
|
|
2090
|
+
if (userMessageCount === userMessageIndex) {
|
|
2091
|
+
targetMessageIndex = i;
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
2094
|
+
userMessageCount++;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
if (targetMessageIndex === -1) {
|
|
2098
|
+
throw new Error(`User message ${userMessageIndex} not found. Session has ${userMessageCount} user messages.`);
|
|
2099
|
+
}
|
|
2100
|
+
logger.info("Found target user message", {
|
|
2101
|
+
sessionId,
|
|
2102
|
+
userMessageIndex,
|
|
2103
|
+
targetMessageIndex,
|
|
2104
|
+
totalMessages: session.messages.length,
|
|
2105
|
+
});
|
|
2106
|
+
// Truncate messages - keep everything BEFORE the target user message
|
|
2107
|
+
session.messages = session.messages.slice(0, targetMessageIndex);
|
|
2108
|
+
// Truncate context entries - filter to only those that reference messages
|
|
2109
|
+
// that are still in the truncated message list
|
|
2110
|
+
session.context = session.context.filter((entry) => {
|
|
2111
|
+
// Find the maximum message index referenced by this context entry
|
|
2112
|
+
let maxPointerIndex = -1;
|
|
2113
|
+
for (const msgEntry of entry.messages) {
|
|
2114
|
+
if (msgEntry.type === "pointer" && msgEntry.index > maxPointerIndex) {
|
|
2115
|
+
maxPointerIndex = msgEntry.index;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
// Keep context entries that only reference messages we still have
|
|
2119
|
+
return maxPointerIndex < targetMessageIndex;
|
|
2120
|
+
});
|
|
2121
|
+
logger.info("Session truncated", {
|
|
2122
|
+
sessionId,
|
|
2123
|
+
newMessageCount: session.messages.length,
|
|
2124
|
+
newContextCount: session.context.length,
|
|
2125
|
+
});
|
|
2126
|
+
// Save the truncated session to disk
|
|
2127
|
+
await this.saveSessionToDisk(sessionId, session);
|
|
2128
|
+
// Now proceed with normal prompt handling
|
|
2129
|
+
return this.prompt({
|
|
2130
|
+
sessionId,
|
|
2131
|
+
prompt: newPrompt,
|
|
2132
|
+
});
|
|
1445
2133
|
}
|
|
1446
2134
|
}
|