@townco/agent 0.1.88 → 0.1.98

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 (32) hide show
  1. package/dist/acp-server/adapter.d.ts +49 -0
  2. package/dist/acp-server/adapter.js +693 -5
  3. package/dist/acp-server/http.d.ts +7 -0
  4. package/dist/acp-server/http.js +53 -6
  5. package/dist/definition/index.d.ts +29 -0
  6. package/dist/definition/index.js +24 -0
  7. package/dist/runner/agent-runner.d.ts +16 -1
  8. package/dist/runner/agent-runner.js +2 -1
  9. package/dist/runner/e2b-sandbox-manager.d.ts +18 -0
  10. package/dist/runner/e2b-sandbox-manager.js +99 -0
  11. package/dist/runner/hooks/executor.d.ts +3 -1
  12. package/dist/runner/hooks/executor.js +21 -1
  13. package/dist/runner/hooks/predefined/compaction-tool.js +67 -2
  14. package/dist/runner/hooks/types.d.ts +5 -0
  15. package/dist/runner/index.d.ts +11 -0
  16. package/dist/runner/langchain/index.d.ts +10 -0
  17. package/dist/runner/langchain/index.js +227 -7
  18. package/dist/runner/langchain/model-factory.js +28 -1
  19. package/dist/runner/langchain/tools/artifacts.js +6 -3
  20. package/dist/runner/langchain/tools/e2b.d.ts +48 -0
  21. package/dist/runner/langchain/tools/e2b.js +305 -0
  22. package/dist/runner/langchain/tools/filesystem.js +63 -0
  23. package/dist/runner/langchain/tools/subagent.d.ts +8 -0
  24. package/dist/runner/langchain/tools/subagent.js +76 -4
  25. package/dist/runner/langchain/tools/web_search.d.ts +36 -14
  26. package/dist/runner/langchain/tools/web_search.js +33 -2
  27. package/dist/runner/session-context.d.ts +20 -0
  28. package/dist/runner/session-context.js +54 -0
  29. package/dist/runner/tools.d.ts +2 -2
  30. package/dist/runner/tools.js +1 -0
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. 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 - return basic info
151
- return { name: tool, description: `Built-in tool: ${tool}` };
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
- this.sessions.get(params.sessionId)?.pendingPrompt?.abort();
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
  }