@townco/agent 0.1.49 → 0.1.51
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 +15 -0
- package/dist/acp-server/adapter.js +445 -67
- package/dist/acp-server/http.js +8 -1
- package/dist/acp-server/session-storage.d.ts +19 -0
- package/dist/acp-server/session-storage.js +9 -0
- package/dist/definition/index.d.ts +16 -4
- package/dist/definition/index.js +17 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +10 -1
- package/dist/runner/agent-runner.d.ts +13 -2
- package/dist/runner/agent-runner.js +4 -0
- package/dist/runner/hooks/executor.d.ts +18 -1
- package/dist/runner/hooks/executor.js +74 -62
- package/dist/runner/hooks/predefined/compaction-tool.js +19 -3
- package/dist/runner/hooks/predefined/tool-response-compactor.d.ts +6 -0
- package/dist/runner/hooks/predefined/tool-response-compactor.js +461 -0
- package/dist/runner/hooks/registry.js +2 -0
- package/dist/runner/hooks/types.d.ts +39 -3
- package/dist/runner/hooks/types.js +9 -1
- package/dist/runner/langchain/index.d.ts +1 -0
- package/dist/runner/langchain/index.js +523 -321
- package/dist/runner/langchain/model-factory.js +1 -1
- package/dist/runner/langchain/otel-callbacks.d.ts +18 -0
- package/dist/runner/langchain/otel-callbacks.js +123 -0
- package/dist/runner/langchain/tools/subagent.js +21 -1
- package/dist/scaffold/link-local.d.ts +1 -0
- package/dist/scaffold/link-local.js +54 -0
- package/dist/scaffold/project-scaffold.js +1 -0
- package/dist/telemetry/index.d.ts +83 -0
- package/dist/telemetry/index.js +172 -0
- package/dist/telemetry/setup.d.ts +22 -0
- package/dist/telemetry/setup.js +141 -0
- package/dist/templates/index.d.ts +7 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/context-size-calculator.d.ts +29 -0
- package/dist/utils/context-size-calculator.js +78 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/token-counter.d.ts +19 -0
- package/dist/utils/token-counter.js +44 -0
- package/index.ts +16 -1
- package/package.json +24 -7
- package/templates/index.ts +18 -6
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
|
|
2
|
+
import { context, propagation, trace } from "@opentelemetry/api";
|
|
2
3
|
import { createLogger } from "@townco/core";
|
|
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";
|
|
7
|
+
import { telemetry } from "../../telemetry/index.js";
|
|
6
8
|
import { loadCustomToolModule, } from "../tool-loader.js";
|
|
7
|
-
import { createModelFromString } from "./model-factory.js";
|
|
9
|
+
import { createModelFromString, detectProvider } from "./model-factory.js";
|
|
10
|
+
import { makeOtelCallbacks } from "./otel-callbacks.js";
|
|
8
11
|
import { makeFilesystemTools } from "./tools/filesystem";
|
|
9
12
|
import { TASK_TOOL_NAME } from "./tools/subagent";
|
|
10
13
|
import { TODO_WRITE_TOOL_NAME, todoWrite } from "./tools/todo";
|
|
@@ -51,369 +54,568 @@ async function loadCustomTools(modulePaths) {
|
|
|
51
54
|
}
|
|
52
55
|
export class LangchainAgent {
|
|
53
56
|
definition;
|
|
57
|
+
toolSpans = new Map();
|
|
54
58
|
constructor(params) {
|
|
55
59
|
this.definition = params;
|
|
56
60
|
}
|
|
57
61
|
async *invoke(req) {
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
62
|
+
// Derive the parent OTEL context for this invocation.
|
|
63
|
+
// If this is a subagent and the parent process propagated an OTEL trace
|
|
64
|
+
// context via sessionMeta.otelTraceContext, use that as the parent;
|
|
65
|
+
// otherwise, fall back to the current active context.
|
|
66
|
+
let parentContext = context.active();
|
|
67
|
+
const meta = req.sessionMeta;
|
|
68
|
+
if (meta?.otelTraceContext) {
|
|
69
|
+
parentContext = propagation.extract(parentContext, meta.otelTraceContext);
|
|
70
|
+
}
|
|
71
|
+
// Track turn-level token usage from API responses
|
|
72
|
+
const turnTokenUsage = {
|
|
73
|
+
inputTokens: 0,
|
|
74
|
+
outputTokens: 0,
|
|
75
|
+
totalTokens: 0,
|
|
76
|
+
};
|
|
77
|
+
const countedMessageIds = new Set();
|
|
78
|
+
// Start telemetry span for entire invocation
|
|
79
|
+
const invocationSpan = telemetry.startSpan("agent.invoke", {
|
|
80
|
+
"agent.model": this.definition.model,
|
|
81
|
+
"agent.subagent": meta?.[SUBAGENT_MODE_KEY] === true,
|
|
82
|
+
"agent.session_id": req.sessionId,
|
|
83
|
+
"agent.message_id": req.messageId,
|
|
84
|
+
}, parentContext);
|
|
85
|
+
// Create a context with the invocation span as active
|
|
86
|
+
// This will be used when creating child spans (tool calls)
|
|
87
|
+
const invocationContext = invocationSpan
|
|
88
|
+
? trace.setSpan(parentContext, invocationSpan)
|
|
89
|
+
: parentContext;
|
|
90
|
+
telemetry.log("info", "Agent invocation started", {
|
|
91
|
+
model: this.definition.model,
|
|
92
|
+
sessionId: req.sessionId,
|
|
93
|
+
messageId: req.messageId,
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
// Track todo_write tool call IDs to suppress their tool_call notifications
|
|
97
|
+
const todoWriteToolCallIds = new Set();
|
|
98
|
+
// --------------------------------------------------------------------------
|
|
99
|
+
// Resolve tools: built-ins (string) + custom ({ type: "custom", modulePath })
|
|
100
|
+
// + filesystem ({ type: "filesystem", working_directory? })
|
|
101
|
+
// --------------------------------------------------------------------------
|
|
102
|
+
const enabledTools = [];
|
|
103
|
+
const toolDefs = this.definition.tools ?? [];
|
|
104
|
+
const builtInNames = [];
|
|
105
|
+
const customToolPaths = [];
|
|
106
|
+
for (const t of toolDefs) {
|
|
107
|
+
if (typeof t === "string") {
|
|
108
|
+
builtInNames.push(t);
|
|
83
109
|
}
|
|
84
|
-
else if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
else if (t && typeof t === "object" && "type" in t) {
|
|
111
|
+
const type = t.type;
|
|
112
|
+
if (type === "custom" &&
|
|
113
|
+
"modulePath" in t &&
|
|
114
|
+
typeof t.modulePath === "string") {
|
|
115
|
+
customToolPaths.push(t.modulePath);
|
|
116
|
+
}
|
|
117
|
+
else if (type === "filesystem") {
|
|
118
|
+
const wd = t.working_directory ??
|
|
119
|
+
process.cwd();
|
|
120
|
+
enabledTools.push(...makeFilesystemTools(wd));
|
|
121
|
+
}
|
|
122
|
+
else if (type === "direct") {
|
|
123
|
+
// Handle direct tool objects (imported in code)
|
|
124
|
+
// biome-ignore lint/suspicious/noExplicitAny: mlai unsure how to best type this
|
|
125
|
+
const addedTool = tool(t.fn, {
|
|
126
|
+
name: t.name,
|
|
127
|
+
description: t.description,
|
|
128
|
+
schema: t.schema,
|
|
129
|
+
});
|
|
130
|
+
addedTool.prettyName = t.prettyName;
|
|
131
|
+
addedTool.icon = t.icon;
|
|
132
|
+
enabledTools.push(addedTool);
|
|
133
|
+
}
|
|
95
134
|
}
|
|
96
135
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
// Built-in tools from registry
|
|
137
|
+
for (const name of builtInNames) {
|
|
138
|
+
const entry = TOOL_REGISTRY[name];
|
|
139
|
+
if (!entry) {
|
|
140
|
+
throw new Error(`Unknown built-in tool "${name}"`);
|
|
141
|
+
}
|
|
142
|
+
if (typeof entry === "function") {
|
|
143
|
+
const result = entry();
|
|
144
|
+
if (Array.isArray(result)) {
|
|
145
|
+
enabledTools.push(...result);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
enabledTools.push(result);
|
|
149
|
+
}
|
|
108
150
|
}
|
|
109
151
|
else {
|
|
110
|
-
enabledTools.push(
|
|
152
|
+
enabledTools.push(entry);
|
|
111
153
|
}
|
|
112
154
|
}
|
|
155
|
+
// Custom tools loaded from modulePaths
|
|
156
|
+
if (customToolPaths.length > 0) {
|
|
157
|
+
const customTools = await loadCustomTools(customToolPaths);
|
|
158
|
+
enabledTools.push(...customTools);
|
|
159
|
+
}
|
|
160
|
+
// MCP tools
|
|
161
|
+
if ((this.definition.mcps?.length ?? 0) > 0) {
|
|
162
|
+
enabledTools.push(...(await makeMcpToolsClient(this.definition.mcps).getTools()));
|
|
163
|
+
}
|
|
164
|
+
// Wrap tools with response compaction if hook is configured
|
|
165
|
+
const hooks = this.definition.hooks ?? [];
|
|
166
|
+
const hasToolResponseHook = hooks.some((h) => h.type === "tool_response");
|
|
167
|
+
const noSession = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true; // Subagents don't have session storage
|
|
168
|
+
// Track cumulative tool output tokens in this turn for proper context calculation
|
|
169
|
+
let cumulativeToolOutputTokens = 0;
|
|
170
|
+
let wrappedTools = enabledTools;
|
|
171
|
+
if (hasToolResponseHook && !noSession) {
|
|
172
|
+
const { countToolResultTokens } = await import("../../utils/token-counter.js");
|
|
173
|
+
const { toolResponseCompactor } = await import("../hooks/predefined/tool-response-compactor.js");
|
|
174
|
+
wrappedTools = enabledTools.map((originalTool) => {
|
|
175
|
+
const wrappedFunc = async (input) => {
|
|
176
|
+
// Execute the original tool
|
|
177
|
+
const result = await originalTool.invoke(input);
|
|
178
|
+
// Check if result should be compacted
|
|
179
|
+
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
|
|
180
|
+
const rawOutput = { content: resultStr };
|
|
181
|
+
const outputTokens = countToolResultTokens(rawOutput);
|
|
182
|
+
// Skip compaction for small results (under 10k tokens)
|
|
183
|
+
if (outputTokens < 10000) {
|
|
184
|
+
// Still track this in cumulative total
|
|
185
|
+
cumulativeToolOutputTokens += outputTokens;
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
_logger.info("Tool wrapper: compacting large tool result", {
|
|
189
|
+
toolName: originalTool.name,
|
|
190
|
+
originalTokens: outputTokens,
|
|
191
|
+
cumulativeToolOutputTokens,
|
|
192
|
+
});
|
|
193
|
+
// Calculate current context including all tool outputs so far in this turn
|
|
194
|
+
// This ensures we account for multiple large tool calls in the same turn
|
|
195
|
+
const baseContextTokens = turnTokenUsage.inputTokens || 10000;
|
|
196
|
+
const currentTokens = baseContextTokens + cumulativeToolOutputTokens;
|
|
197
|
+
const maxTokens = 200000; // Claude's limit
|
|
198
|
+
// Build proper hook context with all required fields
|
|
199
|
+
const hookContext = {
|
|
200
|
+
session: {
|
|
201
|
+
messages: req.contextMessages || [],
|
|
202
|
+
context: [],
|
|
203
|
+
requestParams: {
|
|
204
|
+
hookSettings: hooks.find((h) => h.type === "tool_response")
|
|
205
|
+
?.setting,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
currentTokens,
|
|
209
|
+
maxTokens,
|
|
210
|
+
percentage: (currentTokens / maxTokens) * 100,
|
|
211
|
+
model: this.definition.model,
|
|
212
|
+
toolResponse: {
|
|
213
|
+
toolCallId: "pending",
|
|
214
|
+
toolName: originalTool.name,
|
|
215
|
+
toolInput: input,
|
|
216
|
+
rawOutput,
|
|
217
|
+
outputTokens,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
// Call the tool response compactor directly
|
|
221
|
+
const hookResult = await toolResponseCompactor(hookContext);
|
|
222
|
+
// Extract modified output from metadata
|
|
223
|
+
if (hookResult?.metadata?.modifiedOutput) {
|
|
224
|
+
const modifiedOutput = hookResult.metadata
|
|
225
|
+
.modifiedOutput;
|
|
226
|
+
const compactedTokens = countToolResultTokens(modifiedOutput);
|
|
227
|
+
// Update cumulative total with the compacted size (not original!)
|
|
228
|
+
cumulativeToolOutputTokens += compactedTokens;
|
|
229
|
+
_logger.info("Tool wrapper: compaction complete", {
|
|
230
|
+
toolName: originalTool.name,
|
|
231
|
+
originalTokens: outputTokens,
|
|
232
|
+
compactedTokens,
|
|
233
|
+
reduction: `${((1 - compactedTokens / outputTokens) * 100).toFixed(1)}%`,
|
|
234
|
+
totalCumulativeTokens: cumulativeToolOutputTokens,
|
|
235
|
+
});
|
|
236
|
+
return typeof result === "string"
|
|
237
|
+
? modifiedOutput.content
|
|
238
|
+
: JSON.stringify(modifiedOutput);
|
|
239
|
+
}
|
|
240
|
+
// No compaction happened, count original size
|
|
241
|
+
cumulativeToolOutputTokens += outputTokens;
|
|
242
|
+
return result;
|
|
243
|
+
};
|
|
244
|
+
// Create new tool with wrapped function
|
|
245
|
+
const wrappedTool = tool(wrappedFunc, {
|
|
246
|
+
name: originalTool.name,
|
|
247
|
+
description: originalTool.description,
|
|
248
|
+
schema: originalTool.schema,
|
|
249
|
+
});
|
|
250
|
+
// Preserve metadata
|
|
251
|
+
wrappedTool.prettyName = originalTool.prettyName;
|
|
252
|
+
wrappedTool.icon = originalTool.icon;
|
|
253
|
+
return wrappedTool;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// Filter tools if running in subagent mode
|
|
257
|
+
const isSubagent = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true;
|
|
258
|
+
const finalTools = isSubagent
|
|
259
|
+
? wrappedTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
|
|
260
|
+
: wrappedTools;
|
|
261
|
+
// Create the model instance using the factory
|
|
262
|
+
// This detects the provider from the model string:
|
|
263
|
+
// - "gemini-2.0-flash" → Google Generative AI
|
|
264
|
+
// - "vertex-gemini-2.0-flash" → Vertex AI (strips prefix)
|
|
265
|
+
// - "claude-sonnet-4-5-20250929" → Anthropic
|
|
266
|
+
const model = createModelFromString(this.definition.model);
|
|
267
|
+
const agentConfig = {
|
|
268
|
+
model,
|
|
269
|
+
tools: finalTools,
|
|
270
|
+
};
|
|
271
|
+
if (this.definition.systemPrompt) {
|
|
272
|
+
agentConfig.systemPrompt = this.definition.systemPrompt;
|
|
273
|
+
}
|
|
274
|
+
// Inject system prompt with optional TodoWrite instructions
|
|
275
|
+
const hasTodoWrite = builtInNames.includes("todo_write");
|
|
276
|
+
if (hasTodoWrite) {
|
|
277
|
+
agentConfig.systemPrompt = `${agentConfig.systemPrompt ?? ""}\n\n${TODO_WRITE_INSTRUCTIONS}`;
|
|
278
|
+
}
|
|
279
|
+
const agent = createAgent(agentConfig);
|
|
280
|
+
// Add logging callbacks for model requests
|
|
281
|
+
const provider = detectProvider(this.definition.model);
|
|
282
|
+
// Build messages from context history if available, otherwise use just the prompt
|
|
283
|
+
let messages;
|
|
284
|
+
if (req.contextMessages && req.contextMessages.length > 0) {
|
|
285
|
+
// Use context messages (already resolved from context entries)
|
|
286
|
+
// Convert to LangChain format
|
|
287
|
+
messages = req.contextMessages.map((msg) => ({
|
|
288
|
+
type: msg.role === "user" ? "human" : "ai",
|
|
289
|
+
// Extract text from content blocks
|
|
290
|
+
content: msg.content
|
|
291
|
+
.filter((block) => block.type === "text")
|
|
292
|
+
.map((block) => block.text)
|
|
293
|
+
.join(""),
|
|
294
|
+
}));
|
|
295
|
+
// Add the current prompt as the final human message
|
|
296
|
+
const currentPromptText = req.prompt
|
|
297
|
+
.filter((promptMsg) => promptMsg.type === "text")
|
|
298
|
+
.map((promptMsg) => promptMsg.text)
|
|
299
|
+
.join("\n");
|
|
300
|
+
messages.push({
|
|
301
|
+
type: "human",
|
|
302
|
+
content: currentPromptText,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
113
305
|
else {
|
|
114
|
-
|
|
306
|
+
// Fallback: No context history, use just the prompt
|
|
307
|
+
messages = req.prompt
|
|
308
|
+
.filter((promptMsg) => promptMsg.type === "text")
|
|
309
|
+
.map((promptMsg) => ({
|
|
310
|
+
type: "human",
|
|
311
|
+
content: promptMsg.text,
|
|
312
|
+
}));
|
|
115
313
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
// MCP tools
|
|
123
|
-
if ((this.definition.mcps?.length ?? 0) > 0) {
|
|
124
|
-
enabledTools.push(...(await makeMcpToolsClient(this.definition.mcps).getTools()));
|
|
125
|
-
}
|
|
126
|
-
// Filter tools if running in subagent mode
|
|
127
|
-
const isSubagent = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true;
|
|
128
|
-
const finalTools = isSubagent
|
|
129
|
-
? enabledTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
|
|
130
|
-
: enabledTools;
|
|
131
|
-
// Create the model instance using the factory
|
|
132
|
-
// This detects the provider from the model string:
|
|
133
|
-
// - "gemini-2.0-flash" → Google Generative AI
|
|
134
|
-
// - "vertex-gemini-2.0-flash" → Vertex AI (strips prefix)
|
|
135
|
-
// - "claude-sonnet-4-5-20250929" → Anthropic
|
|
136
|
-
const model = createModelFromString(this.definition.model);
|
|
137
|
-
const agentConfig = {
|
|
138
|
-
model,
|
|
139
|
-
tools: finalTools,
|
|
140
|
-
};
|
|
141
|
-
if (this.definition.systemPrompt) {
|
|
142
|
-
agentConfig.systemPrompt = this.definition.systemPrompt;
|
|
143
|
-
}
|
|
144
|
-
// Inject system prompt with optional TodoWrite instructions
|
|
145
|
-
const hasTodoWrite = builtInNames.includes("todo_write");
|
|
146
|
-
if (hasTodoWrite) {
|
|
147
|
-
agentConfig.systemPrompt = `${agentConfig.systemPrompt ?? ""}\n\n${TODO_WRITE_INSTRUCTIONS}`;
|
|
148
|
-
}
|
|
149
|
-
const agent = createAgent(agentConfig);
|
|
150
|
-
// Build messages from context history if available, otherwise use just the prompt
|
|
151
|
-
let messages;
|
|
152
|
-
if (req.contextMessages && req.contextMessages.length > 0) {
|
|
153
|
-
// Use context messages (already resolved from context entries)
|
|
154
|
-
// Convert to LangChain format
|
|
155
|
-
messages = req.contextMessages.map((msg) => ({
|
|
156
|
-
type: msg.role === "user" ? "human" : "ai",
|
|
157
|
-
// Extract text from content blocks
|
|
158
|
-
content: msg.content
|
|
159
|
-
.filter((block) => block.type === "text")
|
|
160
|
-
.map((block) => block.text)
|
|
161
|
-
.join(""),
|
|
162
|
-
}));
|
|
163
|
-
// Add the current prompt as the final human message
|
|
164
|
-
const currentPromptText = req.prompt
|
|
165
|
-
.filter((promptMsg) => promptMsg.type === "text")
|
|
166
|
-
.map((promptMsg) => promptMsg.text)
|
|
167
|
-
.join("\n");
|
|
168
|
-
messages.push({
|
|
169
|
-
type: "human",
|
|
170
|
-
content: currentPromptText,
|
|
314
|
+
// Create OTEL callbacks for instrumentation
|
|
315
|
+
const otelCallbacks = makeOtelCallbacks({
|
|
316
|
+
provider,
|
|
317
|
+
model: this.definition.model,
|
|
318
|
+
parentContext: invocationContext,
|
|
171
319
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
type: "human",
|
|
179
|
-
content: promptMsg.text,
|
|
320
|
+
// Create the stream within the invocation context so AsyncLocalStorage
|
|
321
|
+
// propagates the context to all tool executions and callbacks
|
|
322
|
+
const stream = context.with(invocationContext, () => agent.stream({ messages }, {
|
|
323
|
+
streamMode: ["updates", "messages"],
|
|
324
|
+
recursionLimit: 200,
|
|
325
|
+
callbacks: [otelCallbacks],
|
|
180
326
|
}));
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
327
|
+
for await (const streamItem of await stream) {
|
|
328
|
+
const [streamMode, chunk] = streamItem;
|
|
329
|
+
if (streamMode === "updates") {
|
|
330
|
+
const updatesChunk = modelRequestSchema.safeParse(chunk);
|
|
331
|
+
if (!updatesChunk.success) {
|
|
332
|
+
// Other kinds of updates are either handled in the 'messages'
|
|
333
|
+
// streamMode (tool calls), or we don't care about them so far (not
|
|
334
|
+
// known yet).
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const updatesMessages = updatesChunk.data.model_request.messages;
|
|
338
|
+
if (!updatesMessages.every((m) => m instanceof AIMessageChunk)) {
|
|
339
|
+
throw new Error(`Unhandled updates message chunk types: ${JSON.stringify(updatesMessages)}`);
|
|
340
|
+
}
|
|
341
|
+
for (const msg of updatesMessages) {
|
|
342
|
+
// Extract token usage metadata if available
|
|
343
|
+
const tokenUsage = msg.usage_metadata
|
|
344
|
+
? {
|
|
345
|
+
inputTokens: msg.usage_metadata.input_tokens,
|
|
346
|
+
outputTokens: msg.usage_metadata.output_tokens,
|
|
347
|
+
totalTokens: msg.usage_metadata.total_tokens,
|
|
348
|
+
}
|
|
349
|
+
: undefined;
|
|
350
|
+
// Record token usage in telemetry
|
|
351
|
+
if (tokenUsage) {
|
|
352
|
+
telemetry.recordTokenUsage(tokenUsage.inputTokens ?? 0, tokenUsage.outputTokens ?? 0, invocationSpan);
|
|
206
353
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
354
|
+
// Accumulate token usage (deduplicate by message ID)
|
|
355
|
+
if (tokenUsage && msg.id && !countedMessageIds.has(msg.id)) {
|
|
356
|
+
turnTokenUsage.inputTokens += tokenUsage.inputTokens ?? 0;
|
|
357
|
+
turnTokenUsage.outputTokens += tokenUsage.outputTokens ?? 0;
|
|
358
|
+
turnTokenUsage.totalTokens += tokenUsage.totalTokens ?? 0;
|
|
359
|
+
countedMessageIds.add(msg.id);
|
|
360
|
+
}
|
|
361
|
+
for (const toolCall of msg.tool_calls ?? []) {
|
|
362
|
+
if (toolCall.id == null) {
|
|
363
|
+
throw new Error(`Tool call is missing id: ${JSON.stringify(toolCall)}`);
|
|
364
|
+
}
|
|
365
|
+
// Create tool span within the invocation context
|
|
366
|
+
// This makes the tool span a child of the invocation span
|
|
367
|
+
const toolSpan = context.with(invocationContext, () => telemetry.startSpan("agent.tool_call", {
|
|
368
|
+
"tool.name": toolCall.name,
|
|
369
|
+
"tool.id": toolCall.id,
|
|
370
|
+
}));
|
|
371
|
+
this.toolSpans.set(toolCall.id, toolSpan);
|
|
372
|
+
telemetry.log("info", `Tool call started: ${toolCall.name}`, {
|
|
373
|
+
toolCallId: toolCall.id,
|
|
374
|
+
toolName: toolCall.name,
|
|
375
|
+
toolArgs: JSON.stringify(toolCall.args),
|
|
376
|
+
});
|
|
377
|
+
// TODO: re-add this suppression of the todo_write tool call when we
|
|
378
|
+
// are rendering the agent-plan update in the UIs
|
|
379
|
+
// If this is a todo_write tool call, yield an agent-plan update
|
|
380
|
+
//if (toolCall.name === "todo_write" && toolCall.args?.todos) {
|
|
381
|
+
// const entries = toolCall.args.todos.flatMap((todo: unknown) => {
|
|
382
|
+
// const validation = todoItemSchema.safeParse(todo);
|
|
383
|
+
// if (!validation.success) {
|
|
384
|
+
// // Invalid todo - filter it out
|
|
385
|
+
// return [];
|
|
386
|
+
// }
|
|
387
|
+
// return [
|
|
388
|
+
// {
|
|
389
|
+
// content: validation.data.content,
|
|
390
|
+
// status: validation.data.status,
|
|
391
|
+
// priority: "medium" as const,
|
|
392
|
+
// },
|
|
393
|
+
// ];
|
|
394
|
+
// });
|
|
395
|
+
// yield {
|
|
396
|
+
// sessionUpdate: "plan",
|
|
397
|
+
// entries: entries,
|
|
398
|
+
// };
|
|
399
|
+
// // Track this tool call ID to suppress tool_call notifications
|
|
400
|
+
// todoWriteToolCallIds.add(toolCall.id);
|
|
401
|
+
// continue;
|
|
402
|
+
//}
|
|
403
|
+
const matchingTool = finalTools.find((t) => t.name === toolCall.name);
|
|
404
|
+
const prettyName = matchingTool?.prettyName;
|
|
405
|
+
const icon = matchingTool?.icon;
|
|
406
|
+
yield {
|
|
407
|
+
sessionUpdate: "tool_call",
|
|
408
|
+
toolCallId: toolCall.id,
|
|
409
|
+
title: toolCall.name,
|
|
410
|
+
kind: "other",
|
|
411
|
+
status: "pending",
|
|
412
|
+
rawInput: toolCall.args,
|
|
413
|
+
...(tokenUsage ? { tokenUsage } : {}),
|
|
414
|
+
_meta: {
|
|
415
|
+
messageId: req.messageId,
|
|
416
|
+
...(prettyName ? { prettyName } : {}),
|
|
417
|
+
...(icon ? { icon } : {}),
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
yield {
|
|
421
|
+
sessionUpdate: "tool_call_update",
|
|
422
|
+
toolCallId: toolCall.id,
|
|
423
|
+
status: "in_progress",
|
|
424
|
+
...(tokenUsage ? { tokenUsage } : {}),
|
|
425
|
+
_meta: { messageId: req.messageId },
|
|
426
|
+
};
|
|
211
427
|
}
|
|
212
|
-
// TODO: re-add this suppression of the todo_write tool call when we
|
|
213
|
-
// are rendering the agent-plan update in the UIs
|
|
214
|
-
// If this is a todo_write tool call, yield an agent-plan update
|
|
215
|
-
//if (toolCall.name === "todo_write" && toolCall.args?.todos) {
|
|
216
|
-
// const entries = toolCall.args.todos.flatMap((todo: unknown) => {
|
|
217
|
-
// const validation = todoItemSchema.safeParse(todo);
|
|
218
|
-
// if (!validation.success) {
|
|
219
|
-
// // Invalid todo - filter it out
|
|
220
|
-
// return [];
|
|
221
|
-
// }
|
|
222
|
-
// return [
|
|
223
|
-
// {
|
|
224
|
-
// content: validation.data.content,
|
|
225
|
-
// status: validation.data.status,
|
|
226
|
-
// priority: "medium" as const,
|
|
227
|
-
// },
|
|
228
|
-
// ];
|
|
229
|
-
// });
|
|
230
|
-
// yield {
|
|
231
|
-
// sessionUpdate: "plan",
|
|
232
|
-
// entries: entries,
|
|
233
|
-
// };
|
|
234
|
-
// // Track this tool call ID to suppress tool_call notifications
|
|
235
|
-
// todoWriteToolCallIds.add(toolCall.id);
|
|
236
|
-
// continue;
|
|
237
|
-
//}
|
|
238
|
-
const matchingTool = finalTools.find((t) => t.name === toolCall.name);
|
|
239
|
-
const prettyName = matchingTool?.prettyName;
|
|
240
|
-
const icon = matchingTool?.icon;
|
|
241
|
-
yield {
|
|
242
|
-
sessionUpdate: "tool_call",
|
|
243
|
-
toolCallId: toolCall.id,
|
|
244
|
-
title: toolCall.name,
|
|
245
|
-
kind: "other",
|
|
246
|
-
status: "pending",
|
|
247
|
-
rawInput: toolCall.args,
|
|
248
|
-
...(tokenUsage ? { tokenUsage } : {}),
|
|
249
|
-
_meta: {
|
|
250
|
-
messageId: req.messageId,
|
|
251
|
-
...(prettyName ? { prettyName } : {}),
|
|
252
|
-
...(icon ? { icon } : {}),
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
yield {
|
|
256
|
-
sessionUpdate: "tool_call_update",
|
|
257
|
-
toolCallId: toolCall.id,
|
|
258
|
-
status: "in_progress",
|
|
259
|
-
...(tokenUsage ? { tokenUsage } : {}),
|
|
260
|
-
_meta: { messageId: req.messageId },
|
|
261
|
-
};
|
|
262
428
|
}
|
|
263
429
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
430
|
+
else if (streamMode === "messages") {
|
|
431
|
+
const aiMessage = chunk[0];
|
|
432
|
+
if (aiMessage instanceof AIMessageChunk) {
|
|
433
|
+
// Extract token usage metadata if available
|
|
434
|
+
const messageTokenUsage = aiMessage.usage_metadata
|
|
435
|
+
? {
|
|
436
|
+
inputTokens: aiMessage.usage_metadata.input_tokens,
|
|
437
|
+
outputTokens: aiMessage.usage_metadata.output_tokens,
|
|
438
|
+
totalTokens: aiMessage.usage_metadata.total_tokens,
|
|
439
|
+
}
|
|
440
|
+
: undefined;
|
|
441
|
+
// Accumulate token usage (deduplicate by message ID)
|
|
442
|
+
if (messageTokenUsage &&
|
|
443
|
+
aiMessage.id &&
|
|
444
|
+
!countedMessageIds.has(aiMessage.id)) {
|
|
445
|
+
turnTokenUsage.inputTokens += messageTokenUsage.inputTokens ?? 0;
|
|
446
|
+
turnTokenUsage.outputTokens +=
|
|
447
|
+
messageTokenUsage.outputTokens ?? 0;
|
|
448
|
+
turnTokenUsage.totalTokens += messageTokenUsage.totalTokens ?? 0;
|
|
449
|
+
countedMessageIds.add(aiMessage.id);
|
|
274
450
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const contentLength = contentIsArray
|
|
280
|
-
? aiMessage.content.length
|
|
281
|
-
: typeof aiMessage.content === "string"
|
|
451
|
+
if (messageTokenUsage) {
|
|
452
|
+
const contentType = typeof aiMessage.content;
|
|
453
|
+
const contentIsArray = Array.isArray(aiMessage.content);
|
|
454
|
+
const contentLength = contentIsArray
|
|
282
455
|
? aiMessage.content.length
|
|
283
|
-
:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
aiMessage.content
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
type: "text",
|
|
304
|
-
text: "", // Empty text, just carrying tokenUsage
|
|
305
|
-
},
|
|
306
|
-
_meta: {
|
|
307
|
-
tokenUsage: messageTokenUsage,
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
yield msgToYield;
|
|
311
|
-
continue; // Skip the rest of the processing for this chunk
|
|
312
|
-
}
|
|
313
|
-
if (typeof aiMessage.content === "string") {
|
|
314
|
-
const msgToYield = messageTokenUsage
|
|
315
|
-
? {
|
|
456
|
+
: typeof aiMessage.content === "string"
|
|
457
|
+
? aiMessage.content.length
|
|
458
|
+
: -1;
|
|
459
|
+
_logger.debug("messageTokenUsage", {
|
|
460
|
+
messageTokenUsage,
|
|
461
|
+
contentType,
|
|
462
|
+
isArray: contentIsArray,
|
|
463
|
+
length: contentLength,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
// If we have tokenUsage but no content, send a token-only chunk
|
|
467
|
+
if (messageTokenUsage &&
|
|
468
|
+
(typeof aiMessage.content === "string"
|
|
469
|
+
? aiMessage.content === ""
|
|
470
|
+
: Array.isArray(aiMessage.content) &&
|
|
471
|
+
aiMessage.content.length === 0)) {
|
|
472
|
+
_logger.debug("sending token-only chunk", {
|
|
473
|
+
messageTokenUsage,
|
|
474
|
+
});
|
|
475
|
+
const msgToYield = {
|
|
316
476
|
sessionUpdate: "agent_message_chunk",
|
|
317
477
|
content: {
|
|
318
478
|
type: "text",
|
|
319
|
-
text:
|
|
479
|
+
text: "", // Empty text, just carrying tokenUsage
|
|
320
480
|
},
|
|
321
481
|
_meta: {
|
|
322
482
|
tokenUsage: messageTokenUsage,
|
|
323
483
|
},
|
|
324
|
-
}
|
|
325
|
-
: {
|
|
326
|
-
sessionUpdate: "agent_message_chunk",
|
|
327
|
-
content: {
|
|
328
|
-
type: "text",
|
|
329
|
-
text: aiMessage.content,
|
|
330
|
-
},
|
|
331
484
|
};
|
|
332
|
-
|
|
485
|
+
yield msgToYield;
|
|
486
|
+
continue; // Skip the rest of the processing for this chunk
|
|
487
|
+
}
|
|
488
|
+
if (typeof aiMessage.content === "string") {
|
|
489
|
+
const msgToYield = messageTokenUsage
|
|
490
|
+
? {
|
|
491
|
+
sessionUpdate: "agent_message_chunk",
|
|
492
|
+
content: {
|
|
493
|
+
type: "text",
|
|
494
|
+
text: aiMessage.content,
|
|
495
|
+
},
|
|
496
|
+
_meta: {
|
|
497
|
+
tokenUsage: messageTokenUsage,
|
|
498
|
+
},
|
|
499
|
+
}
|
|
500
|
+
: {
|
|
501
|
+
sessionUpdate: "agent_message_chunk",
|
|
502
|
+
content: {
|
|
503
|
+
type: "text",
|
|
504
|
+
text: aiMessage.content,
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
yield msgToYield;
|
|
508
|
+
}
|
|
509
|
+
else if (Array.isArray(aiMessage.content)) {
|
|
510
|
+
for (const part of aiMessage.content) {
|
|
511
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
512
|
+
const msgToYield = messageTokenUsage
|
|
513
|
+
? {
|
|
514
|
+
sessionUpdate: "agent_message_chunk",
|
|
515
|
+
content: {
|
|
516
|
+
type: "text",
|
|
517
|
+
text: part.text,
|
|
518
|
+
},
|
|
519
|
+
_meta: {
|
|
520
|
+
tokenUsage: messageTokenUsage,
|
|
521
|
+
},
|
|
522
|
+
}
|
|
523
|
+
: {
|
|
524
|
+
sessionUpdate: "agent_message_chunk",
|
|
525
|
+
content: {
|
|
526
|
+
type: "text",
|
|
527
|
+
text: part.text,
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
yield msgToYield;
|
|
531
|
+
}
|
|
532
|
+
else if (part.type === "tool_use") {
|
|
533
|
+
// We don't care about tool use chunks -- do nothing
|
|
534
|
+
}
|
|
535
|
+
else if (part.type === "input_json_delta") {
|
|
536
|
+
// We don't care about tool use input delta chunks -- do nothing
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
throw new Error(`Unhandled AIMessageChunk content block type: ${part.type}\n${JSON.stringify(part)}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
throw new Error(`Unhandled AIMessageChunk content type: ${typeof aiMessage.content}`);
|
|
545
|
+
}
|
|
333
546
|
}
|
|
334
|
-
else if (
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
547
|
+
else if (aiMessage instanceof ToolMessage) {
|
|
548
|
+
if (typeof aiMessage.content === "string") {
|
|
549
|
+
if (todoWriteToolCallIds.has(aiMessage.tool_call_id)) {
|
|
550
|
+
// Skip tool_call_update for todo_write tools
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
// End telemetry span for this tool call
|
|
554
|
+
const toolSpan = this.toolSpans.get(aiMessage.tool_call_id);
|
|
555
|
+
if (toolSpan) {
|
|
556
|
+
telemetry.log("info", "Tool call completed", {
|
|
557
|
+
toolCallId: aiMessage.tool_call_id,
|
|
558
|
+
});
|
|
559
|
+
telemetry.endSpan(toolSpan);
|
|
560
|
+
this.toolSpans.delete(aiMessage.tool_call_id);
|
|
561
|
+
}
|
|
562
|
+
// Send status update (metadata only, no content)
|
|
563
|
+
yield {
|
|
564
|
+
sessionUpdate: "tool_call_update",
|
|
565
|
+
toolCallId: aiMessage.tool_call_id,
|
|
566
|
+
status: "completed",
|
|
567
|
+
_meta: { messageId: req.messageId },
|
|
568
|
+
};
|
|
569
|
+
// Send tool output separately (via direct SSE, bypassing PostgreSQL NOTIFY)
|
|
570
|
+
yield {
|
|
571
|
+
sessionUpdate: "tool_output",
|
|
572
|
+
toolCallId: aiMessage.tool_call_id,
|
|
573
|
+
content: [
|
|
574
|
+
{
|
|
575
|
+
type: "content",
|
|
350
576
|
content: {
|
|
351
577
|
type: "text",
|
|
352
|
-
text:
|
|
578
|
+
text: aiMessage.content,
|
|
353
579
|
},
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
else if (part.type === "input_json_delta") {
|
|
361
|
-
// We don't care about tool use input delta chunks -- do nothing
|
|
362
|
-
}
|
|
363
|
-
else {
|
|
364
|
-
throw new Error(`Unhandled AIMessageChunk content block type: ${part.type}\n${JSON.stringify(part)}`);
|
|
365
|
-
}
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
rawOutput: { content: aiMessage.content },
|
|
583
|
+
_meta: { messageId: req.messageId },
|
|
584
|
+
};
|
|
366
585
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
throw new Error(`Unhandled AIMessageChunk content type: ${typeof aiMessage.content}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
else if (aiMessage instanceof ToolMessage) {
|
|
373
|
-
if (typeof aiMessage.content === "string") {
|
|
374
|
-
if (todoWriteToolCallIds.has(aiMessage.tool_call_id)) {
|
|
375
|
-
// Skip tool_call_update for todo_write tools
|
|
376
|
-
continue;
|
|
586
|
+
else {
|
|
587
|
+
throw new Error(`Unhandled ToolMessage content type: ${typeof aiMessage.content}`);
|
|
377
588
|
}
|
|
378
|
-
// Send status update (metadata only, no content)
|
|
379
|
-
yield {
|
|
380
|
-
sessionUpdate: "tool_call_update",
|
|
381
|
-
toolCallId: aiMessage.tool_call_id,
|
|
382
|
-
status: "completed",
|
|
383
|
-
_meta: { messageId: req.messageId },
|
|
384
|
-
};
|
|
385
|
-
// Send tool output separately (via direct SSE, bypassing PostgreSQL NOTIFY)
|
|
386
|
-
yield {
|
|
387
|
-
sessionUpdate: "tool_output",
|
|
388
|
-
toolCallId: aiMessage.tool_call_id,
|
|
389
|
-
content: [
|
|
390
|
-
{
|
|
391
|
-
type: "content",
|
|
392
|
-
content: {
|
|
393
|
-
type: "text",
|
|
394
|
-
text: aiMessage.content,
|
|
395
|
-
},
|
|
396
|
-
},
|
|
397
|
-
],
|
|
398
|
-
rawOutput: { content: aiMessage.content },
|
|
399
|
-
_meta: { messageId: req.messageId },
|
|
400
|
-
};
|
|
401
589
|
}
|
|
402
590
|
else {
|
|
403
|
-
throw new Error(`Unhandled
|
|
591
|
+
throw new Error(`Unhandled message chunk type: ${JSON.stringify(aiMessage)}`);
|
|
404
592
|
}
|
|
405
593
|
}
|
|
406
594
|
else {
|
|
407
|
-
throw new Error(`Unhandled
|
|
595
|
+
throw new Error(`Unhandled stream mode: ${streamMode}`);
|
|
408
596
|
}
|
|
409
597
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
598
|
+
// Log successful completion
|
|
599
|
+
telemetry.log("info", "Agent invocation completed", {
|
|
600
|
+
sessionId: req.sessionId,
|
|
601
|
+
});
|
|
602
|
+
telemetry.endSpan(invocationSpan);
|
|
603
|
+
return {
|
|
604
|
+
stopReason: "end_turn",
|
|
605
|
+
_meta: {
|
|
606
|
+
tokenUsage: turnTokenUsage,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
// Log error and end span with error status
|
|
612
|
+
telemetry.log("error", "Agent invocation failed", {
|
|
613
|
+
error: error instanceof Error ? error.message : String(error),
|
|
614
|
+
sessionId: req.sessionId,
|
|
615
|
+
});
|
|
616
|
+
telemetry.endSpan(invocationSpan, error instanceof Error ? error : new Error(String(error)));
|
|
617
|
+
throw error;
|
|
413
618
|
}
|
|
414
|
-
return {
|
|
415
|
-
stopReason: "end_turn",
|
|
416
|
-
};
|
|
417
619
|
}
|
|
418
620
|
}
|
|
419
621
|
const modelRequestSchema = z.object({
|