@townco/agent 0.1.55 → 0.1.57

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.
@@ -1,5 +1,6 @@
1
1
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
2
2
  import { context, propagation, trace } from "@opentelemetry/api";
3
+ import { loadAuthCredentials } from "@townco/core/auth";
3
4
  import { AIMessageChunk, createAgent, ToolMessage, tool, } from "langchain";
4
5
  import { z } from "zod";
5
6
  import { SUBAGENT_MODE_KEY } from "../../acp-server/adapter";
@@ -8,9 +9,11 @@ import { telemetry } from "../../telemetry/index.js";
8
9
  import { loadCustomToolModule, } from "../tool-loader.js";
9
10
  import { createModelFromString, detectProvider } from "./model-factory.js";
10
11
  import { makeOtelCallbacks } from "./otel-callbacks.js";
12
+ import { makeBrowserTools } from "./tools/browser";
11
13
  import { makeFilesystemTools } from "./tools/filesystem";
12
14
  import { makeGenerateImageTool } from "./tools/generate_image";
13
15
  import { SUBAGENT_TOOL_NAME } from "./tools/subagent";
16
+ import { hashQuery, queryToToolCallId, subagentEvents, } from "./tools/subagent-connections";
14
17
  import { TODO_WRITE_TOOL_NAME, todoWrite } from "./tools/todo";
15
18
  import { makeWebSearchTools } from "./tools/web_search";
16
19
  const _logger = createLogger("agent-runner");
@@ -29,6 +32,7 @@ export const TOOL_REGISTRY = {
29
32
  web_search: () => makeWebSearchTools(),
30
33
  filesystem: () => makeFilesystemTools(process.cwd()),
31
34
  generate_image: () => makeGenerateImageTool(),
35
+ browser: () => makeBrowserTools(),
32
36
  };
33
37
  // ============================================================================
34
38
  // Custom tool loading
@@ -78,11 +82,64 @@ export class LangchainAgent {
78
82
  const countedMessageIds = new Set();
79
83
  // Track tool calls for which we've emitted preliminary notifications (from early tool_use blocks)
80
84
  const preliminaryToolCallIds = new Set();
85
+ // Set session_id as a base attribute so all spans in this invocation include it
86
+ telemetry.setBaseAttributes({
87
+ "agent.session_id": req.sessionId,
88
+ });
89
+ const subagentUpdateQueue = [];
90
+ let subagentUpdateResolver = null;
91
+ // Listen for subagent connection events - resolve any waiting promise immediately
92
+ const onSubagentConnection = (event) => {
93
+ _logger.info("Received subagent connection event", {
94
+ toolCallId: event.toolCallId,
95
+ port: event.port,
96
+ sessionId: event.sessionId,
97
+ });
98
+ if (subagentUpdateResolver) {
99
+ // If someone is waiting, resolve immediately
100
+ const resolver = subagentUpdateResolver;
101
+ subagentUpdateResolver = null;
102
+ resolver(event);
103
+ }
104
+ else {
105
+ // Otherwise queue for later
106
+ subagentUpdateQueue.push(event);
107
+ }
108
+ };
109
+ subagentEvents.on("connection", onSubagentConnection);
110
+ // Helper to get next subagent update (returns immediately if queued, otherwise waits)
111
+ const waitForSubagentUpdate = () => {
112
+ if (subagentUpdateQueue.length > 0) {
113
+ return Promise.resolve(subagentUpdateQueue.shift());
114
+ }
115
+ return new Promise((resolve) => {
116
+ subagentUpdateResolver = resolve;
117
+ });
118
+ };
119
+ // Helper to check and yield all pending subagent updates
120
+ async function* yieldPendingSubagentUpdates() {
121
+ while (subagentUpdateQueue.length > 0) {
122
+ const update = subagentUpdateQueue.shift();
123
+ _logger.info("Yielding queued subagent connection update", {
124
+ toolCallId: update.toolCallId,
125
+ subagentPort: update.port,
126
+ subagentSessionId: update.sessionId,
127
+ });
128
+ yield {
129
+ sessionUpdate: "tool_call_update",
130
+ toolCallId: update.toolCallId,
131
+ _meta: {
132
+ messageId: req.messageId,
133
+ subagentPort: update.port,
134
+ subagentSessionId: update.sessionId,
135
+ },
136
+ };
137
+ }
138
+ }
81
139
  // Start telemetry span for entire invocation
82
140
  const invocationSpan = telemetry.startSpan("agent.invoke", {
83
141
  "agent.model": this.definition.model,
84
142
  "agent.subagent": meta?.[SUBAGENT_MODE_KEY] === true,
85
- "agent.session_id": req.sessionId,
86
143
  "agent.message_id": req.messageId,
87
144
  }, parentContext);
88
145
  // Create a context with the invocation span as active
@@ -294,7 +351,7 @@ export class LangchainAgent {
294
351
  : wrappedTools;
295
352
  // Wrap tools with tracing so each tool executes within its own span context.
296
353
  // This ensures subagent spans are children of the Task tool span.
297
- const finalTools = filteredTools.map((t) => wrapToolWithTracing(t, req.sessionId));
354
+ const finalTools = filteredTools.map((t) => wrapToolWithTracing(t));
298
355
  // Create the model instance using the factory
299
356
  // This detects the provider from the model string:
300
357
  // - "gemini-2.0-flash" → Google Generative AI
@@ -419,7 +476,53 @@ export class LangchainAgent {
419
476
  recursionLimit: 200,
420
477
  callbacks: [otelCallbacks],
421
478
  }));
422
- for await (const streamItem of await stream) {
479
+ // Use manual iteration with Promise.race to interleave stream events with subagent updates
480
+ const streamIterator = (await stream)[Symbol.asyncIterator]();
481
+ let streamDone = false;
482
+ let pendingStreamPromise = null;
483
+ while (!streamDone) {
484
+ // Get or create the stream promise (reuse if still pending from last iteration)
485
+ const nextStreamPromise = pendingStreamPromise ?? streamIterator.next();
486
+ pendingStreamPromise = nextStreamPromise; // Track it
487
+ // Create subagent wait promise (only if no queued updates)
488
+ const subagentPromise = waitForSubagentUpdate();
489
+ // Use Promise.race, but we need to handle both outcomes
490
+ const result = await Promise.race([
491
+ nextStreamPromise.then((r) => ({
492
+ type: "stream",
493
+ result: r,
494
+ })),
495
+ subagentPromise.then((u) => ({ type: "subagent", update: u })),
496
+ ]);
497
+ if (result.type === "subagent") {
498
+ // Got a subagent update - yield it immediately
499
+ const update = result.update;
500
+ _logger.info("Yielding subagent connection update (via race)", {
501
+ toolCallId: update.toolCallId,
502
+ subagentPort: update.port,
503
+ subagentSessionId: update.sessionId,
504
+ });
505
+ yield {
506
+ sessionUpdate: "tool_call_update",
507
+ toolCallId: update.toolCallId,
508
+ _meta: {
509
+ messageId: req.messageId,
510
+ subagentPort: update.port,
511
+ subagentSessionId: update.sessionId,
512
+ },
513
+ };
514
+ // Continue - the stream promise is still pending, will be reused
515
+ continue;
516
+ }
517
+ // Got a stream item - clear the pending promise
518
+ pendingStreamPromise = null;
519
+ const { done, value: streamItem } = result.result;
520
+ if (done) {
521
+ streamDone = true;
522
+ break;
523
+ }
524
+ // Also yield any queued subagent updates before processing stream item
525
+ yield* yieldPendingSubagentUpdates();
423
526
  const [streamMode, chunk] = streamItem;
424
527
  if (streamMode === "updates") {
425
528
  const updatesChunk = modelRequestSchema.safeParse(chunk);
@@ -511,6 +614,17 @@ export class LangchainAgent {
511
614
  const subagentConfigs = taskTool?.subagentConfigs;
512
615
  const subagentConfig = subagentConfigs?.find((config) => config.agentName === agentName);
513
616
  prettyName = subagentConfig?.displayName ?? agentName;
617
+ // Register query hash -> toolCallId mapping for subagent connection info
618
+ if ("query" in toolCall.args &&
619
+ typeof toolCall.args.query === "string") {
620
+ const qHash = hashQuery(toolCall.args.query);
621
+ queryToToolCallId.set(qHash, toolCall.id);
622
+ telemetry.log("info", "Registered subagent query hash mapping", {
623
+ queryHash: qHash,
624
+ toolCallId: toolCall.id,
625
+ queryPreview: toolCall.args.query.slice(0, 50),
626
+ });
627
+ }
514
628
  }
515
629
  // Check if we already emitted a preliminary notification from early tool_use block
516
630
  const alreadyEmittedPreliminary = preliminaryToolCallIds.has(toolCall.id);
@@ -744,6 +858,16 @@ export class LangchainAgent {
744
858
  else {
745
859
  throw new Error(`Unhandled stream mode: ${streamMode}`);
746
860
  }
861
+ // Yield any pending subagent connection updates
862
+ yield* yieldPendingSubagentUpdates();
863
+ }
864
+ // Yield any remaining pending subagent connection updates after stream ends
865
+ yield* yieldPendingSubagentUpdates();
866
+ // Clean up subagent connection listener
867
+ subagentEvents.off("connection", onSubagentConnection);
868
+ // Cancel any pending wait
869
+ if (subagentUpdateResolver) {
870
+ subagentUpdateResolver = null;
747
871
  }
748
872
  // Log successful completion
749
873
  telemetry.log("info", "Agent invocation completed", {
@@ -758,6 +882,8 @@ export class LangchainAgent {
758
882
  };
759
883
  }
760
884
  catch (error) {
885
+ // Clean up subagent connection listener on error
886
+ subagentEvents.off("connection", onSubagentConnection);
761
887
  // Log error and end span with error status
762
888
  telemetry.log("error", "Agent invocation failed", {
763
889
  error: error instanceof Error ? error.message : String(error),
@@ -776,12 +902,19 @@ const modelRequestSchema = z.object({
776
902
  const makeMcpToolsClient = (mcpConfigs) => {
777
903
  const mcpServers = mcpConfigs?.map((config) => {
778
904
  if (typeof config === "string") {
779
- // Default to localhost:3000/mcp_proxy if not specified
780
- const proxyUrl = process.env.MCP_PROXY_URL || "http://localhost:3000/mcp_proxy";
905
+ // String configs use the centralized MCP proxy with auth
906
+ const credentials = loadAuthCredentials();
907
+ if (!credentials) {
908
+ throw new Error("Not logged in. Run 'town login' first to use cloud MCP servers.");
909
+ }
910
+ const proxyUrl = process.env.MCP_PROXY_URL ?? `${credentials.shed_url}/mcp_proxy`;
781
911
  return [
782
912
  config,
783
913
  {
784
914
  url: `${proxyUrl}?server=${config}`,
915
+ headers: {
916
+ Authorization: `Bearer ${credentials.access_token}`,
917
+ },
785
918
  },
786
919
  ];
787
920
  }
@@ -874,13 +1007,12 @@ export { makeSubagentsTool } from "./tools/subagent.js";
874
1007
  * so any child operations (like subagent spawning) become children
875
1008
  * of the tool span rather than the parent invocation span.
876
1009
  */
877
- function wrapToolWithTracing(originalTool, sessionId) {
1010
+ function wrapToolWithTracing(originalTool) {
878
1011
  const wrappedFunc = async (input) => {
879
1012
  const toolInputJson = JSON.stringify(input);
880
1013
  const toolSpan = telemetry.startSpan("agent.tool_call", {
881
1014
  "tool.name": originalTool.name,
882
1015
  "tool.input": toolInputJson,
883
- "agent.session_id": sessionId,
884
1016
  });
885
1017
  // Create a context with the tool span as active
886
1018
  const spanContext = toolSpan
@@ -4,6 +4,7 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
4
4
  * LangChain chat model instance.
5
5
  *
6
6
  * Detection logic:
7
+ * - If model starts with "town-" → Proxied via shed (strips prefix, uses TOWN_SHED_URL)
7
8
  * - If model starts with "vertex-" → Google Vertex AI (strips prefix)
8
9
  * - If model contains "gemini" (unprefixed) → Google Generative AI
9
10
  * - If model contains "gpt" → OpenAI (future support)
@@ -12,6 +13,7 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
12
13
  * Supported formats:
13
14
  * - Direct model name: "gemini-2.0-flash", "vertex-gemini-2.0-flash", "claude-sonnet-4-5-20250929"
14
15
  * - Provider prefix: "google_vertexai:gemini-2.0-flash", "google_genai:gemini-2.0-flash", "anthropic:claude-3-5-sonnet"
16
+ * - Proxied: "town-claude-sonnet-4-5-20250929" (uses TOWN_SHED_URL or defaults to localhost:3000)
15
17
  */
16
18
  export declare function createModelFromString(modelString: string): BaseChatModel;
17
19
  /**
@@ -1,6 +1,7 @@
1
1
  import { ChatAnthropic } from "@langchain/anthropic";
2
2
  import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
3
3
  import { ChatVertexAI } from "@langchain/google-vertexai";
4
+ import { loadAuthCredentials } from "@townco/core/auth";
4
5
  import { createLogger } from "../../logger.js";
5
6
  const logger = createLogger("model-factory");
6
7
  /**
@@ -8,6 +9,7 @@ const logger = createLogger("model-factory");
8
9
  * LangChain chat model instance.
9
10
  *
10
11
  * Detection logic:
12
+ * - If model starts with "town-" → Proxied via shed (strips prefix, uses TOWN_SHED_URL)
11
13
  * - If model starts with "vertex-" → Google Vertex AI (strips prefix)
12
14
  * - If model contains "gemini" (unprefixed) → Google Generative AI
13
15
  * - If model contains "gpt" → OpenAI (future support)
@@ -16,8 +18,25 @@ const logger = createLogger("model-factory");
16
18
  * Supported formats:
17
19
  * - Direct model name: "gemini-2.0-flash", "vertex-gemini-2.0-flash", "claude-sonnet-4-5-20250929"
18
20
  * - Provider prefix: "google_vertexai:gemini-2.0-flash", "google_genai:gemini-2.0-flash", "anthropic:claude-3-5-sonnet"
21
+ * - Proxied: "town-claude-sonnet-4-5-20250929" (uses TOWN_SHED_URL or defaults to localhost:3000)
19
22
  */
20
23
  export function createModelFromString(modelString) {
24
+ // Check for town- prefix for proxied models via shed
25
+ if (modelString.startsWith("town-")) {
26
+ const actualModel = modelString.slice(5); // strip "town-"
27
+ const credentials = loadAuthCredentials();
28
+ if (!credentials) {
29
+ throw new Error("Not logged in. Run 'town login' first.");
30
+ }
31
+ const shedUrl = credentials.shed_url ??
32
+ process.env.TOWN_SHED_URL ??
33
+ "http://localhost:3000";
34
+ return new ChatAnthropic({
35
+ model: actualModel,
36
+ anthropicApiUrl: `${shedUrl}/api/anthropic`,
37
+ apiKey: credentials.access_token,
38
+ });
39
+ }
21
40
  // Check if the model string uses provider prefix format
22
41
  const parts = modelString.split(":", 2);
23
42
  const maybeProvider = parts[0];
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+ interface BrowserNavigateResult {
3
+ success: boolean;
4
+ url?: string;
5
+ title?: string;
6
+ liveViewUrl?: string;
7
+ error?: string;
8
+ }
9
+ interface BrowserScreenshotResult {
10
+ success: boolean;
11
+ screenshotPath?: string;
12
+ liveViewUrl?: string;
13
+ error?: string;
14
+ }
15
+ interface BrowserExtractResult {
16
+ success: boolean;
17
+ content?: string;
18
+ url?: string;
19
+ title?: string;
20
+ liveViewUrl?: string;
21
+ error?: string;
22
+ }
23
+ interface BrowserClickResult {
24
+ success: boolean;
25
+ message?: string;
26
+ liveViewUrl?: string;
27
+ error?: string;
28
+ }
29
+ interface BrowserTypeResult {
30
+ success: boolean;
31
+ message?: string;
32
+ liveViewUrl?: string;
33
+ error?: string;
34
+ }
35
+ interface BrowserCloseResult {
36
+ success: boolean;
37
+ message?: string;
38
+ error?: string;
39
+ }
40
+ export declare function makeBrowserTools(): readonly [import("langchain").DynamicStructuredTool<z.ZodObject<{
41
+ url: z.ZodString;
42
+ waitUntil: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
43
+ load: "load";
44
+ domcontentloaded: "domcontentloaded";
45
+ networkidle: "networkidle";
46
+ }>>>;
47
+ }, z.core.$strip>, {
48
+ url: string;
49
+ waitUntil: "load" | "domcontentloaded" | "networkidle";
50
+ }, {
51
+ url: string;
52
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | undefined;
53
+ }, BrowserNavigateResult>, import("langchain").DynamicStructuredTool<z.ZodObject<{
54
+ fullPage: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
55
+ selector: z.ZodOptional<z.ZodString>;
56
+ }, z.core.$strip>, {
57
+ fullPage: boolean;
58
+ selector?: string | undefined;
59
+ }, {
60
+ fullPage?: boolean | undefined;
61
+ selector?: string | undefined;
62
+ }, BrowserScreenshotResult>, import("langchain").DynamicStructuredTool<z.ZodObject<{
63
+ selector: z.ZodOptional<z.ZodString>;
64
+ extractType: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
65
+ text: "text";
66
+ html: "html";
67
+ }>>>;
68
+ }, z.core.$strip>, {
69
+ extractType: "text" | "html";
70
+ selector?: string | undefined;
71
+ }, {
72
+ selector?: string | undefined;
73
+ extractType?: "text" | "html" | undefined;
74
+ }, BrowserExtractResult>, import("langchain").DynamicStructuredTool<z.ZodObject<{
75
+ selector: z.ZodString;
76
+ button: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
77
+ left: "left";
78
+ right: "right";
79
+ middle: "middle";
80
+ }>>>;
81
+ }, z.core.$strip>, {
82
+ selector: string;
83
+ button: "left" | "right" | "middle";
84
+ }, {
85
+ selector: string;
86
+ button?: "left" | "right" | "middle" | undefined;
87
+ }, BrowserClickResult>, import("langchain").DynamicStructuredTool<z.ZodObject<{
88
+ selector: z.ZodString;
89
+ text: z.ZodString;
90
+ pressEnter: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
91
+ }, z.core.$strip>, {
92
+ selector: string;
93
+ text: string;
94
+ pressEnter: boolean;
95
+ }, {
96
+ selector: string;
97
+ text: string;
98
+ pressEnter?: boolean | undefined;
99
+ }, BrowserTypeResult>, import("langchain").DynamicStructuredTool<z.ZodObject<{}, z.core.$strip>, Record<string, never>, Record<string, never>, BrowserCloseResult>];
100
+ export {};