@townco/agent 0.1.119 → 0.1.121

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,14 +2,81 @@ 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 Anthropic from "@anthropic-ai/sdk";
5
6
  import { context, propagation, trace } from "@opentelemetry/api";
6
7
  import { createLogger } from "@townco/core";
7
8
  import { z } from "zod";
8
- import { SUBAGENT_MODE_KEY, } from "../../../acp-server/adapter.js";
9
+ import { AgentAcpAdapter, SUBAGENT_MODE_KEY, } from "../../../acp-server/adapter.js";
9
10
  import { makeRunnerFromDefinition } from "../../index.js";
10
- import { bindGeneratorToSessionContext, getAbortSignal, } from "../../session-context.js";
11
+ import { bindGeneratorToSessionContext, getAbortSignal, getSessionContext, } from "../../session-context.js";
11
12
  import { emitSubagentMessages, hashQuery, } from "./subagent-connections.js";
12
13
  const logger = createLogger("subagent-tool", "debug");
14
+ /**
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.
20
+ */
21
+ async function generateStatusMessage(recentContent, toolCalls) {
22
+ try {
23
+ const activeTool = toolCalls.find((tc) => tc.status === "in_progress");
24
+ // Use Anthropic SDK directly to bypass LangChain callbacks entirely
25
+ const anthropic = new Anthropic({
26
+ apiKey: process.env.ANTHROPIC_API_KEY,
27
+ });
28
+ const prompt = `Summarize the current activity in 5-7 words for a progress indicator:
29
+
30
+ Recent output: ${recentContent.slice(-500)}
31
+ ${activeTool ? `Active tool: ${activeTool.prettyName || activeTool.title}` : ""}
32
+
33
+ Requirements:
34
+ - Use present continuous tense (e.g., "Searching for...", "Analyzing...")
35
+ - Be specific but concise
36
+ - Focus on user-visible progress
37
+ - Return ONLY the status, no explanation
38
+
39
+ 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);
54
+ }
55
+ catch (error) {
56
+ logger.warn("Failed to generate status message", { error });
57
+ return extractHeuristicStatus(recentContent, toolCalls);
58
+ }
59
+ }
60
+ /**
61
+ * Heuristic status extraction (fallback when LLM fails)
62
+ */
63
+ function extractHeuristicStatus(content, toolCalls) {
64
+ // Priority 1: Active tool
65
+ const activeTool = toolCalls.find((tc) => tc.status === "in_progress");
66
+ if (activeTool) {
67
+ return `${activeTool.prettyName || activeTool.title}...`;
68
+ }
69
+ // Priority 2: First complete sentence from recent content
70
+ if (content.length > 50) {
71
+ const lastAdded = content.slice(-200);
72
+ const firstSentence = lastAdded.match(/[^.!?]+[.!?]/)?.[0];
73
+ if (firstSentence && firstSentence.length > 10) {
74
+ return firstSentence.trim().slice(0, 80);
75
+ }
76
+ }
77
+ // Priority 3: Generic fallback
78
+ return "Processing...";
79
+ }
13
80
  /**
14
81
  * Helper to derive favicon URL from a domain
15
82
  */
@@ -311,98 +378,36 @@ assistant: "I'm going to use the Task tool to launch the greeting-responder agen
311
378
  .enum(agentNames)
312
379
  .describe("The name of the subagent to use"),
313
380
  query: z.string().describe("The query or task to send to the subagent"),
381
+ taskName: z
382
+ .string()
383
+ .describe("A concise 3-5 word name describing this specific task (e.g., 'Searching for React patterns', 'Analyzing API responses'). IMPORTANT: Be specific and action-oriented!"),
314
384
  }),
315
385
  // Expose subagent configs for metadata extraction by the adapter
316
386
  subagentConfigs,
317
387
  fn: async (input) => {
318
- const { agentName, query } = input;
388
+ const { agentName, query, taskName } = input;
319
389
  const agent = agentMap.get(agentName);
320
390
  if (!agent) {
321
391
  throw new Error(`Unknown agent: ${agentName}`);
322
392
  }
323
- return await querySubagent(agentName, agent.agentPath, agent.agentDir, query);
393
+ return await querySubagent(agentName, agent.agentPath, agent.agentDir, query, taskName);
324
394
  },
325
395
  };
326
396
  }
327
397
  /**
328
- * 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.
329
401
  */
330
- async function querySubagent(agentName, agentPath, agentWorkingDirectory, query) {
331
- // Get the abort signal from context (set by parent agent's cancellation)
332
- const parentAbortSignal = getAbortSignal();
333
- // Check if already cancelled before starting
334
- if (parentAbortSignal?.aborted) {
335
- throw new Error(`Subagent query cancelled before starting (agent: ${agentName})`);
336
- }
337
- // Validate that the agent exists
338
- try {
339
- await fs.access(agentPath);
340
- }
341
- catch (_error) {
342
- throw new Error(`Subagent '${agentName}' not found at ${agentPath}. Make sure the agent exists and has an index.ts file.`);
343
- }
344
- // Load agent definition dynamically
345
- const agentModule = await import(agentPath);
346
- const agentDefinition = agentModule.default || agentModule.agent;
347
- // Create runner instance
348
- const runner = makeRunnerFromDefinition(agentDefinition);
349
- // Generate unique session ID for isolation
350
- const subagentSessionId = crypto.randomUUID();
351
- // Setup session paths
352
- const sessionDir = path.join(agentWorkingDirectory, ".sessions", subagentSessionId);
353
- const artifactsDir = path.join(sessionDir, "artifacts");
354
- await mkdir(artifactsDir, { recursive: true });
355
- // Prepare OTEL context for distributed tracing
356
- const activeCtx = context.active();
357
- const activeSpan = trace.getSpan(activeCtx);
358
- const otelCarrier = {};
359
- if (activeSpan) {
360
- propagation.inject(trace.setSpan(activeCtx, activeSpan), otelCarrier);
361
- }
362
- // Create invoke request
363
- const invokeRequest = {
364
- sessionId: subagentSessionId,
365
- messageId: crypto.randomUUID(),
366
- prompt: [{ type: "text", text: query }],
367
- agentDir: agentWorkingDirectory,
368
- contextMessages: [],
369
- ...(parentAbortSignal ? { abortSignal: parentAbortSignal } : {}),
370
- sessionMeta: {
371
- [SUBAGENT_MODE_KEY]: true,
372
- ...(Object.keys(otelCarrier).length > 0
373
- ? { otelTraceContext: otelCarrier }
374
- : {}),
375
- },
376
- };
377
- // Bind session context and invoke
378
- let generator = runner.invoke(invokeRequest);
379
- generator = bindGeneratorToSessionContext({ sessionId: subagentSessionId, sessionDir, artifactsDir }, generator);
380
- // Consume stream, accumulate results, and emit incremental updates
381
- let responseText = "";
382
- const collectedSources = [];
383
- const sourceCounter = { value: 0 }; // Mutable counter for source ID generation
384
- const currentMessage = {
385
- id: `subagent-${Date.now()}`,
386
- content: "",
387
- contentBlocks: [],
388
- toolCalls: [],
389
- };
390
- const toolCallMap = new Map();
391
- const toolNameMap = new Map(); // Map toolCallId -> toolName
392
- const queryHash = hashQuery(query);
393
- logger.info("[DEBUG] Starting subagent generator loop", {
394
- agentName,
395
- queryHash,
396
- sessionId: subagentSessionId,
397
- });
398
- try {
399
- 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;
400
406
  let shouldEmit = false;
401
407
  // Handle agent_message_chunk
402
408
  if (update.sessionUpdate === "agent_message_chunk") {
403
409
  const content = update.content;
404
410
  if (content?.type === "text" && typeof content.text === "string") {
405
- responseText += content.text;
406
411
  currentMessage.content += content.text;
407
412
  const lastBlock = currentMessage.contentBlocks[currentMessage.contentBlocks.length - 1];
408
413
  if (lastBlock && lastBlock.type === "text") {
@@ -414,15 +419,13 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
414
419
  text: content.text,
415
420
  });
416
421
  }
417
- shouldEmit = true; // Emit after each text chunk
422
+ shouldEmit = true;
418
423
  }
419
424
  }
420
425
  // Handle tool_call
421
426
  if (update.sessionUpdate === "tool_call" && update.toolCallId) {
422
427
  const meta = update._meta;
423
- // Extract rawInput from the update
424
- const rawInput = update
425
- .rawInput;
428
+ const rawInput = update.rawInput;
426
429
  const toolCall = {
427
430
  id: update.toolCallId,
428
431
  title: update.title || "Tool call",
@@ -433,12 +436,11 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
433
436
  };
434
437
  currentMessage.toolCalls.push(toolCall);
435
438
  toolCallMap.set(update.toolCallId, currentMessage.toolCalls.length - 1);
436
- // Store tool name for source extraction when output arrives
437
439
  if (update.title) {
438
440
  toolNameMap.set(update.toolCallId, update.title);
439
441
  }
440
442
  currentMessage.contentBlocks.push({ type: "tool_call", toolCall });
441
- shouldEmit = true; // Emit when new tool call appears
443
+ shouldEmit = true;
442
444
  }
443
445
  // Handle tool_call_update
444
446
  if (update.sessionUpdate === "tool_call_update" && update.toolCallId) {
@@ -452,25 +454,22 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
452
454
  if (block && update.status) {
453
455
  block.toolCall.status = update.status;
454
456
  }
455
- shouldEmit = true; // Emit when tool status changes
457
+ shouldEmit = true;
456
458
  }
457
459
  }
458
- // Handle tool_output - capture rawOutput and extract sources
459
- // (rawOutput will be included in final emit when subagent completes)
460
+ // Handle tool_output
460
461
  if (update.sessionUpdate === "tool_output" && update.toolCallId) {
461
- const outputUpdate = update;
462
462
  const idx = toolCallMap.get(update.toolCallId);
463
463
  if (idx !== undefined && currentMessage.toolCalls[idx]) {
464
- if (outputUpdate.rawOutput) {
465
- currentMessage.toolCalls[idx].rawOutput = outputUpdate.rawOutput;
466
- // Also update the content block
464
+ const rawOutput = update.rawOutput;
465
+ if (rawOutput) {
466
+ currentMessage.toolCalls[idx].rawOutput = rawOutput;
467
467
  const block = currentMessage.contentBlocks.find((b) => b.type === "tool_call" && b.toolCall.id === update.toolCallId);
468
468
  if (block) {
469
- block.toolCall.rawOutput = outputUpdate.rawOutput;
469
+ block.toolCall.rawOutput = rawOutput;
470
470
  }
471
- // Extract citation sources from tool output (WebSearch, WebFetch, library tools)
472
471
  const toolName = toolNameMap.get(update.toolCallId) || "";
473
- const extractedSources = extractSourcesFromToolOutput(toolName, outputUpdate.rawOutput, update.toolCallId, sourceCounter);
472
+ const extractedSources = extractSourcesFromToolOutput(toolName, rawOutput, update.toolCallId, sourceCounter);
474
473
  if (extractedSources.length > 0) {
475
474
  collectedSources.push(...extractedSources);
476
475
  logger.info("Extracted sources from subagent tool output", {
@@ -478,46 +477,21 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
478
477
  toolCallId: update.toolCallId,
479
478
  sourcesCount: extractedSources.length,
480
479
  });
481
- shouldEmit = true; // Emit when sources are extracted
480
+ shouldEmit = true;
482
481
  }
483
482
  }
484
- // NOTE: Don't set shouldEmit for rawOutput alone - rawOutput is large and will be
485
- // included in the final emit when the subagent completes
486
483
  }
487
484
  }
488
- // Handle sources (from ACP protocol)
489
- if ("sources" in update &&
490
- Array.isArray(update.sources)) {
491
- const sources = update
492
- .sources;
485
+ // Handle sources from ACP
486
+ if (update.sessionUpdate === "sources" && Array.isArray(update.sources)) {
487
+ const sources = update.sources;
493
488
  for (const source of sources) {
494
- const citationSource = {
495
- id: source.id,
496
- url: source.url,
497
- title: source.title,
498
- toolCallId: source.toolCallId,
499
- };
500
- if (source.snippet)
501
- citationSource.snippet = source.snippet;
502
- if (source.favicon)
503
- citationSource.favicon = source.favicon;
504
- if (source.sourceName)
505
- citationSource.sourceName = source.sourceName;
506
- collectedSources.push(citationSource);
489
+ collectedSources.push(source);
507
490
  }
508
- shouldEmit = true; // Emit when sources are added
491
+ shouldEmit = true;
509
492
  }
510
- // Emit incremental update to parent (for live streaming)
493
+ // Emit incremental update
511
494
  if (shouldEmit) {
512
- logger.debug("[SUBAGENT-ACCUMULATION] Emitting incremental update", {
513
- agentName,
514
- queryHash,
515
- contentLength: currentMessage.content.length,
516
- contentBlocksCount: currentMessage.contentBlocks.length,
517
- toolCallsCount: currentMessage.toolCalls.length,
518
- });
519
- // Strip rawOutput from streamed messages to avoid OOM in browser
520
- // rawOutput is preserved in currentMessage for final session save
521
495
  const streamMessage = {
522
496
  ...currentMessage,
523
497
  toolCalls: currentMessage.toolCalls.map((tc) => {
@@ -534,8 +508,108 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
534
508
  };
535
509
  emitSubagentMessages(queryHash, [streamMessage]);
536
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;
537
594
  }
538
- logger.info("[DEBUG] Subagent generator loop finished", {
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;
601
+ }
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", {
539
613
  agentName,
540
614
  queryHash,
541
615
  sessionId: subagentSessionId,
@@ -553,7 +627,6 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
553
627
  emitSubagentMessages(queryHash, [currentMessage], true);
554
628
  }
555
629
  else {
556
- // Even if no messages, emit completion sentinel
557
630
  logger.info("[DEBUG] Emitting empty completion flag", {
558
631
  agentName,
559
632
  queryHash,
@@ -567,8 +640,9 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
567
640
  queryHash,
568
641
  sessionId: subagentSessionId,
569
642
  });
643
+ // Return accumulated result from streaming connection
570
644
  return {
571
- text: responseText,
645
+ text: currentMessage.content,
572
646
  sources: collectedSources,
573
647
  };
574
648
  }