@townco/agent 0.1.120 → 0.1.122

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.
@@ -2,25 +2,28 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
- import { ChatAnthropic } from "@langchain/anthropic";
5
+ import Anthropic from "@anthropic-ai/sdk";
6
6
  import { context, propagation, trace } from "@opentelemetry/api";
7
7
  import { createLogger } from "@townco/core";
8
8
  import { z } from "zod";
9
- import { SUBAGENT_MODE_KEY, } from "../../../acp-server/adapter.js";
9
+ import { AgentAcpAdapter, SUBAGENT_MODE_KEY, } from "../../../acp-server/adapter.js";
10
10
  import { makeRunnerFromDefinition } from "../../index.js";
11
- import { bindGeneratorToSessionContext, getAbortSignal, } from "../../session-context.js";
11
+ import { bindGeneratorToSessionContext, getAbortSignal, getSessionContext, } from "../../session-context.js";
12
12
  import { emitSubagentMessages, hashQuery, } from "./subagent-connections.js";
13
13
  const logger = createLogger("subagent-tool", "debug");
14
14
  /**
15
15
  * Generate status message using Haiku (fast, cheap model)
16
+ *
17
+ * Important: Uses Anthropic SDK directly (not LangChain) to completely isolate
18
+ * from the LangChain streaming context and prevent status text from leaking
19
+ * into the subagent's content stream.
16
20
  */
17
21
  async function generateStatusMessage(recentContent, toolCalls) {
18
22
  try {
19
23
  const activeTool = toolCalls.find((tc) => tc.status === "in_progress");
20
- const model = new ChatAnthropic({
21
- modelName: "claude-3-haiku-20240307",
22
- temperature: 0.3,
23
- maxTokens: 30,
24
+ // Use Anthropic SDK directly to bypass LangChain callbacks entirely
25
+ const anthropic = new Anthropic({
26
+ apiKey: process.env.ANTHROPIC_API_KEY,
24
27
  });
25
28
  const prompt = `Summarize the current activity in 5-7 words for a progress indicator:
26
29
 
@@ -34,9 +37,20 @@ Requirements:
34
37
  - Return ONLY the status, no explanation
35
38
 
36
39
  Status:`;
37
- const response = await model.invoke(prompt);
38
- const status = response.content.toString().trim().slice(0, 80);
39
- return status;
40
+ const response = await anthropic.messages.create({
41
+ model: "claude-haiku-4-5-20251001",
42
+ max_tokens: 30,
43
+ temperature: 0.3,
44
+ messages: [
45
+ {
46
+ role: "user",
47
+ content: prompt,
48
+ },
49
+ ],
50
+ });
51
+ const textContent = response.content.find((block) => block.type === "text");
52
+ const status = textContent?.type === "text" ? textContent.text.trim().slice(0, 80) : "";
53
+ return status || extractHeuristicStatus(recentContent, toolCalls);
40
54
  }
41
55
  catch (error) {
42
56
  logger.warn("Failed to generate status message", { error });
@@ -381,116 +395,19 @@ assistant: "I'm going to use the Task tool to launch the greeting-responder agen
381
395
  };
382
396
  }
383
397
  /**
384
- * Internal function that runs a subagent in-process and queries it.
398
+ * Creates an ACP connection that forwards streaming updates to emitSubagentMessages.
399
+ * This allows subagents to run through the adapter layer (getting full session persistence
400
+ * and hook execution) while still providing real-time streaming updates to the parent.
385
401
  */
386
- async function querySubagent(agentName, agentPath, agentWorkingDirectory, query, taskName) {
387
- // Get the abort signal from context (set by parent agent's cancellation)
388
- const parentAbortSignal = getAbortSignal();
389
- // Check if already cancelled before starting
390
- if (parentAbortSignal?.aborted) {
391
- throw new Error(`Subagent query cancelled before starting (agent: ${agentName})`);
392
- }
393
- // Validate that the agent exists
394
- try {
395
- await fs.access(agentPath);
396
- }
397
- catch (_error) {
398
- throw new Error(`Subagent '${agentName}' not found at ${agentPath}. Make sure the agent exists and has an index.ts file.`);
399
- }
400
- // Load agent definition dynamically
401
- const agentModule = await import(agentPath);
402
- const agentDefinition = agentModule.default || agentModule.agent;
403
- // Create runner instance
404
- const runner = makeRunnerFromDefinition(agentDefinition);
405
- // Generate unique session ID for isolation
406
- const subagentSessionId = crypto.randomUUID();
407
- // Setup session paths
408
- const sessionDir = path.join(agentWorkingDirectory, ".sessions", subagentSessionId);
409
- const artifactsDir = path.join(sessionDir, "artifacts");
410
- await mkdir(artifactsDir, { recursive: true });
411
- // Prepare OTEL context for distributed tracing
412
- const activeCtx = context.active();
413
- const activeSpan = trace.getSpan(activeCtx);
414
- const otelCarrier = {};
415
- if (activeSpan) {
416
- propagation.inject(trace.setSpan(activeCtx, activeSpan), otelCarrier);
417
- // Set span attributes for observability
418
- activeSpan.setAttributes({
419
- "subagent.semantic_name": taskName,
420
- "subagent.agent_definition": agentName,
421
- });
422
- }
423
- // Create invoke request
424
- const invokeRequest = {
425
- sessionId: subagentSessionId,
426
- messageId: crypto.randomUUID(),
427
- prompt: [{ type: "text", text: query }],
428
- agentDir: agentWorkingDirectory,
429
- contextMessages: [],
430
- ...(parentAbortSignal ? { abortSignal: parentAbortSignal } : {}),
431
- sessionMeta: {
432
- [SUBAGENT_MODE_KEY]: true,
433
- ...(Object.keys(otelCarrier).length > 0
434
- ? { otelTraceContext: otelCarrier }
435
- : {}),
436
- },
437
- };
438
- // Bind session context and invoke
439
- let generator = runner.invoke(invokeRequest);
440
- generator = bindGeneratorToSessionContext({ sessionId: subagentSessionId, sessionDir, artifactsDir }, generator);
441
- // Consume stream, accumulate results, and emit incremental updates
442
- let responseText = "";
443
- const collectedSources = [];
444
- const sourceCounter = { value: 0 }; // Mutable counter for source ID generation
445
- const currentMessage = {
446
- id: `subagent-${Date.now()}`,
447
- content: "",
448
- contentBlocks: [],
449
- toolCalls: [],
450
- _meta: {
451
- semanticName: taskName,
452
- agentDefinitionName: agentName,
453
- statusGenerating: true,
454
- },
455
- };
456
- const toolCallMap = new Map();
457
- const toolNameMap = new Map(); // Map toolCallId -> toolName
458
- const queryHash = hashQuery(query);
459
- // Emit initial message with semantic name immediately
460
- emitSubagentMessages(queryHash, [currentMessage]);
461
- // Track status updates for periodic generation
462
- let lastStatusUpdate = Date.now();
463
- let statusUpdateInProgress = false;
464
- const STATUS_UPDATE_INTERVAL = 5000; // 5 seconds
465
- // Fire async initial status generation (don't await)
466
- generateStatusMessage(query, [])
467
- .then((status) => {
468
- if (currentMessage._meta) {
469
- currentMessage._meta.currentActivity = status;
470
- currentMessage._meta.statusGenerating = false;
471
- }
472
- // Emit update with status
473
- emitSubagentMessages(queryHash, [currentMessage]);
474
- })
475
- .catch((error) => {
476
- logger.warn("Initial status generation failed", { error });
477
- if (currentMessage._meta) {
478
- currentMessage._meta.statusGenerating = false;
479
- }
480
- });
481
- logger.info("[DEBUG] Starting subagent generator loop", {
482
- agentName,
483
- queryHash,
484
- sessionId: subagentSessionId,
485
- });
486
- try {
487
- for await (const update of generator) {
402
+ function createStreamingConnection(queryHash, currentMessage, toolCallMap, toolNameMap, collectedSources, sourceCounter) {
403
+ return {
404
+ sessionUpdate: (notification) => {
405
+ const update = notification.update;
488
406
  let shouldEmit = false;
489
407
  // Handle agent_message_chunk
490
408
  if (update.sessionUpdate === "agent_message_chunk") {
491
409
  const content = update.content;
492
410
  if (content?.type === "text" && typeof content.text === "string") {
493
- responseText += content.text;
494
411
  currentMessage.content += content.text;
495
412
  const lastBlock = currentMessage.contentBlocks[currentMessage.contentBlocks.length - 1];
496
413
  if (lastBlock && lastBlock.type === "text") {
@@ -502,15 +419,13 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query,
502
419
  text: content.text,
503
420
  });
504
421
  }
505
- shouldEmit = true; // Emit after each text chunk
422
+ shouldEmit = true;
506
423
  }
507
424
  }
508
425
  // Handle tool_call
509
426
  if (update.sessionUpdate === "tool_call" && update.toolCallId) {
510
427
  const meta = update._meta;
511
- // Extract rawInput from the update
512
- const rawInput = update
513
- .rawInput;
428
+ const rawInput = update.rawInput;
514
429
  const toolCall = {
515
430
  id: update.toolCallId,
516
431
  title: update.title || "Tool call",
@@ -521,12 +436,11 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query,
521
436
  };
522
437
  currentMessage.toolCalls.push(toolCall);
523
438
  toolCallMap.set(update.toolCallId, currentMessage.toolCalls.length - 1);
524
- // Store tool name for source extraction when output arrives
525
439
  if (update.title) {
526
440
  toolNameMap.set(update.toolCallId, update.title);
527
441
  }
528
442
  currentMessage.contentBlocks.push({ type: "tool_call", toolCall });
529
- shouldEmit = true; // Emit when new tool call appears
443
+ shouldEmit = true;
530
444
  }
531
445
  // Handle tool_call_update
532
446
  if (update.sessionUpdate === "tool_call_update" && update.toolCallId) {
@@ -540,25 +454,22 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query,
540
454
  if (block && update.status) {
541
455
  block.toolCall.status = update.status;
542
456
  }
543
- shouldEmit = true; // Emit when tool status changes
457
+ shouldEmit = true;
544
458
  }
545
459
  }
546
- // Handle tool_output - capture rawOutput and extract sources
547
- // (rawOutput will be included in final emit when subagent completes)
460
+ // Handle tool_output
548
461
  if (update.sessionUpdate === "tool_output" && update.toolCallId) {
549
- const outputUpdate = update;
550
462
  const idx = toolCallMap.get(update.toolCallId);
551
463
  if (idx !== undefined && currentMessage.toolCalls[idx]) {
552
- if (outputUpdate.rawOutput) {
553
- currentMessage.toolCalls[idx].rawOutput = outputUpdate.rawOutput;
554
- // Also update the content block
464
+ const rawOutput = update.rawOutput;
465
+ if (rawOutput) {
466
+ currentMessage.toolCalls[idx].rawOutput = rawOutput;
555
467
  const block = currentMessage.contentBlocks.find((b) => b.type === "tool_call" && b.toolCall.id === update.toolCallId);
556
468
  if (block) {
557
- block.toolCall.rawOutput = outputUpdate.rawOutput;
469
+ block.toolCall.rawOutput = rawOutput;
558
470
  }
559
- // Extract citation sources from tool output (WebSearch, WebFetch, library tools)
560
471
  const toolName = toolNameMap.get(update.toolCallId) || "";
561
- const extractedSources = extractSourcesFromToolOutput(toolName, outputUpdate.rawOutput, update.toolCallId, sourceCounter);
472
+ const extractedSources = extractSourcesFromToolOutput(toolName, rawOutput, update.toolCallId, sourceCounter);
562
473
  if (extractedSources.length > 0) {
563
474
  collectedSources.push(...extractedSources);
564
475
  logger.info("Extracted sources from subagent tool output", {
@@ -566,69 +477,21 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query,
566
477
  toolCallId: update.toolCallId,
567
478
  sourcesCount: extractedSources.length,
568
479
  });
569
- shouldEmit = true; // Emit when sources are extracted
480
+ shouldEmit = true;
570
481
  }
571
482
  }
572
- // NOTE: Don't set shouldEmit for rawOutput alone - rawOutput is large and will be
573
- // included in the final emit when the subagent completes
574
483
  }
575
484
  }
576
- // Handle sources (from ACP protocol)
577
- if ("sources" in update &&
578
- Array.isArray(update.sources)) {
579
- const sources = update
580
- .sources;
485
+ // Handle sources from ACP
486
+ if (update.sessionUpdate === "sources" && Array.isArray(update.sources)) {
487
+ const sources = update.sources;
581
488
  for (const source of sources) {
582
- const citationSource = {
583
- id: source.id,
584
- url: source.url,
585
- title: source.title,
586
- toolCallId: source.toolCallId,
587
- };
588
- if (source.snippet)
589
- citationSource.snippet = source.snippet;
590
- if (source.favicon)
591
- citationSource.favicon = source.favicon;
592
- if (source.sourceName)
593
- citationSource.sourceName = source.sourceName;
594
- collectedSources.push(citationSource);
489
+ collectedSources.push(source);
595
490
  }
596
- shouldEmit = true; // Emit when sources are added
597
- }
598
- // Periodic status update check
599
- const now = Date.now();
600
- const shouldUpdateStatus = !statusUpdateInProgress &&
601
- now - lastStatusUpdate > STATUS_UPDATE_INTERVAL &&
602
- currentMessage.content.length > 100; // Only if there's meaningful content
603
- if (shouldUpdateStatus) {
604
- statusUpdateInProgress = true;
605
- lastStatusUpdate = now;
606
- // Fire async status update (don't await)
607
- generateStatusMessage(currentMessage.content, currentMessage.toolCalls)
608
- .then((status) => {
609
- if (currentMessage._meta) {
610
- currentMessage._meta.currentActivity = status;
611
- }
612
- // Emit update
613
- emitSubagentMessages(queryHash, [currentMessage]);
614
- statusUpdateInProgress = false;
615
- })
616
- .catch((error) => {
617
- logger.warn("Status update failed", { error });
618
- statusUpdateInProgress = false;
619
- });
491
+ shouldEmit = true;
620
492
  }
621
- // Emit incremental update to parent (for live streaming)
493
+ // Emit incremental update
622
494
  if (shouldEmit) {
623
- logger.debug("[SUBAGENT-ACCUMULATION] Emitting incremental update", {
624
- agentName,
625
- queryHash,
626
- contentLength: currentMessage.content.length,
627
- contentBlocksCount: currentMessage.contentBlocks.length,
628
- toolCallsCount: currentMessage.toolCalls.length,
629
- });
630
- // Strip rawOutput from streamed messages to avoid OOM in browser
631
- // rawOutput is preserved in currentMessage for final session save
632
495
  const streamMessage = {
633
496
  ...currentMessage,
634
497
  toolCalls: currentMessage.toolCalls.map((tc) => {
@@ -645,8 +508,108 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query,
645
508
  };
646
509
  emitSubagentMessages(queryHash, [streamMessage]);
647
510
  }
511
+ },
512
+ };
513
+ }
514
+ /**
515
+ * Internal function that runs a subagent in-process and queries it.
516
+ */
517
+ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query, taskName) {
518
+ // Get the abort signal from context (set by parent agent's cancellation)
519
+ const parentAbortSignal = getAbortSignal();
520
+ // Check if already cancelled before starting
521
+ if (parentAbortSignal?.aborted) {
522
+ throw new Error(`Subagent query cancelled before starting (agent: ${agentName})`);
523
+ }
524
+ // Validate that the agent exists
525
+ try {
526
+ await fs.access(agentPath);
527
+ }
528
+ catch (_error) {
529
+ throw new Error(`Subagent '${agentName}' not found at ${agentPath}. Make sure the agent exists and has an index.ts file.`);
530
+ }
531
+ // Load agent definition dynamically
532
+ const agentModule = await import(agentPath);
533
+ const agentDefinition = agentModule.default || agentModule.agent;
534
+ // Create runner instance
535
+ const runner = makeRunnerFromDefinition(agentDefinition);
536
+ // Generate unique session ID for isolation
537
+ const subagentSessionId = crypto.randomUUID();
538
+ // Prepare OTEL context for distributed tracing
539
+ const activeCtx = context.active();
540
+ const activeSpan = trace.getSpan(activeCtx);
541
+ const otelCarrier = {};
542
+ if (activeSpan) {
543
+ propagation.inject(trace.setSpan(activeCtx, activeSpan), otelCarrier);
544
+ // Set span attributes for observability
545
+ activeSpan.setAttributes({
546
+ "subagent.semantic_name": taskName,
547
+ "subagent.agent_definition": agentName,
548
+ });
549
+ }
550
+ // Setup state for streaming connection
551
+ const collectedSources = [];
552
+ const sourceCounter = { value: 0 };
553
+ const currentMessage = {
554
+ id: `subagent-${Date.now()}`,
555
+ content: "",
556
+ contentBlocks: [],
557
+ toolCalls: [],
558
+ _meta: {
559
+ semanticName: taskName,
560
+ agentDefinitionName: agentName,
561
+ statusGenerating: true,
562
+ },
563
+ };
564
+ const toolCallMap = new Map();
565
+ const toolNameMap = new Map();
566
+ const queryHash = hashQuery(query);
567
+ // Create streaming connection that forwards updates to emitSubagentMessages
568
+ const connection = createStreamingConnection(queryHash, currentMessage, toolCallMap, toolNameMap, collectedSources, sourceCounter);
569
+ // Create adapter with session storage (subagents get full session persistence)
570
+ const adapter = new AgentAcpAdapter(runner, connection, agentWorkingDirectory, agentName);
571
+ // Get parent session context for linking
572
+ const parentSessionContext = getSessionContext();
573
+ // Prepare prompt request (ACP format)
574
+ const promptRequest = {
575
+ sessionId: subagentSessionId,
576
+ prompt: [{ type: "text", text: query }],
577
+ _meta: {
578
+ isSubagentSession: true, // For UI filtering
579
+ parentSessionId: parentSessionContext?.sessionId, // For session linking
580
+ [SUBAGENT_MODE_KEY]: true, // 🚨 CRITICAL: Prevents nested subagents via tool filtering
581
+ ...(Object.keys(otelCarrier).length > 0
582
+ ? { otelTraceContext: otelCarrier }
583
+ : {}),
584
+ },
585
+ };
586
+ // Emit initial message with semantic name immediately
587
+ emitSubagentMessages(queryHash, [currentMessage]);
588
+ // Fire async initial status generation (don't await)
589
+ generateStatusMessage(query, [])
590
+ .then((status) => {
591
+ if (currentMessage._meta) {
592
+ currentMessage._meta.currentActivity = status;
593
+ currentMessage._meta.statusGenerating = false;
594
+ }
595
+ emitSubagentMessages(queryHash, [currentMessage]);
596
+ })
597
+ .catch((error) => {
598
+ logger.warn("Initial status generation failed", { error });
599
+ if (currentMessage._meta) {
600
+ currentMessage._meta.statusGenerating = false;
648
601
  }
649
- logger.info("[DEBUG] Subagent generator loop finished", {
602
+ });
603
+ logger.info("[DEBUG] Starting subagent via adapter", {
604
+ agentName,
605
+ queryHash,
606
+ sessionId: subagentSessionId,
607
+ });
608
+ try {
609
+ // Invoke through adapter (gets full session tracking + hook execution)
610
+ // The adapter will call connection.sessionUpdate() for streaming updates
611
+ const response = await adapter.prompt(promptRequest);
612
+ logger.info("[DEBUG] Subagent adapter.prompt() completed", {
650
613
  agentName,
651
614
  queryHash,
652
615
  sessionId: subagentSessionId,
@@ -664,7 +627,6 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query,
664
627
  emitSubagentMessages(queryHash, [currentMessage], true);
665
628
  }
666
629
  else {
667
- // Even if no messages, emit completion sentinel
668
630
  logger.info("[DEBUG] Emitting empty completion flag", {
669
631
  agentName,
670
632
  queryHash,
@@ -678,8 +640,9 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query,
678
640
  queryHash,
679
641
  sessionId: subagentSessionId,
680
642
  });
643
+ // Return accumulated result from streaming connection
681
644
  return {
682
- text: responseText,
645
+ text: currentMessage.content,
683
646
  sources: collectedSources,
684
647
  };
685
648
  }