@townco/agent 0.1.48 → 0.1.50
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 +5 -0
- package/dist/acp-server/adapter.js +219 -48
- package/dist/acp-server/session-storage.d.ts +5 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +9 -0
- package/dist/runner/agent-runner.d.ts +3 -0
- package/dist/runner/hooks/executor.d.ts +1 -1
- package/dist/runner/hooks/executor.js +8 -62
- package/dist/runner/hooks/predefined/compaction-tool.js +14 -4
- package/dist/runner/hooks/types.d.ts +1 -1
- package/dist/runner/hooks/types.js +4 -1
- package/dist/runner/langchain/index.d.ts +1 -0
- package/dist/runner/langchain/index.js +504 -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/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/tsconfig.tsbuildinfo +1 -1
- package/index.ts +15 -0
- package/package.json +23 -7
|
@@ -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,549 @@ 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
|
+
// Filter tools if running in subagent mode
|
|
165
|
+
const isSubagent = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true;
|
|
166
|
+
const finalTools = isSubagent
|
|
167
|
+
? enabledTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
|
|
168
|
+
: enabledTools;
|
|
169
|
+
// Create the model instance using the factory
|
|
170
|
+
// This detects the provider from the model string:
|
|
171
|
+
// - "gemini-2.0-flash" → Google Generative AI
|
|
172
|
+
// - "vertex-gemini-2.0-flash" → Vertex AI (strips prefix)
|
|
173
|
+
// - "claude-sonnet-4-5-20250929" → Anthropic
|
|
174
|
+
const model = createModelFromString(this.definition.model);
|
|
175
|
+
const agentConfig = {
|
|
176
|
+
model,
|
|
177
|
+
tools: finalTools,
|
|
178
|
+
};
|
|
179
|
+
if (this.definition.systemPrompt) {
|
|
180
|
+
agentConfig.systemPrompt = this.definition.systemPrompt;
|
|
181
|
+
}
|
|
182
|
+
// Inject system prompt with optional TodoWrite instructions
|
|
183
|
+
const hasTodoWrite = builtInNames.includes("todo_write");
|
|
184
|
+
if (hasTodoWrite) {
|
|
185
|
+
agentConfig.systemPrompt = `${agentConfig.systemPrompt ?? ""}\n\n${TODO_WRITE_INSTRUCTIONS}`;
|
|
186
|
+
}
|
|
187
|
+
const agent = createAgent(agentConfig);
|
|
188
|
+
// Add logging callbacks for model requests
|
|
189
|
+
const provider = detectProvider(this.definition.model);
|
|
190
|
+
const loggingCallback = {
|
|
191
|
+
handleChatModelStart: async (_llm, messages, runId, parentRunId, extraParams) => {
|
|
192
|
+
_logger.info("Model request started", {
|
|
193
|
+
provider,
|
|
194
|
+
model: this.definition.model,
|
|
195
|
+
runId,
|
|
196
|
+
parentRunId,
|
|
197
|
+
messageCount: messages.length,
|
|
198
|
+
extraParams,
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
handleLLMEnd: async (output, runId, parentRunId, tags, extraParams) => {
|
|
202
|
+
// Extract token usage from output
|
|
203
|
+
const llmResult = output;
|
|
204
|
+
_logger.info("Model request completed", {
|
|
205
|
+
provider,
|
|
206
|
+
model: this.definition.model,
|
|
207
|
+
runId,
|
|
208
|
+
parentRunId,
|
|
209
|
+
tags,
|
|
210
|
+
tokenUsage: llmResult.llmOutput?.tokenUsage,
|
|
211
|
+
generationCount: llmResult.generations?.length,
|
|
212
|
+
extraParams,
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
handleLLMError: async (error, runId, parentRunId, tags) => {
|
|
216
|
+
_logger.error("Model request failed", {
|
|
217
|
+
provider,
|
|
218
|
+
model: this.definition.model,
|
|
219
|
+
runId,
|
|
220
|
+
parentRunId,
|
|
221
|
+
tags,
|
|
222
|
+
error: error.message,
|
|
223
|
+
stack: error.stack,
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
handleToolStart: async (_tool, input, runId, parentRunId, tags, metadata, runName) => {
|
|
227
|
+
if (process.env.DEBUG_TELEMETRY === "true") {
|
|
228
|
+
console.log(`[handleToolStart] runId=${runId}, runName=${runName}, parentRunId=${parentRunId}`);
|
|
229
|
+
console.log(`[handleToolStart] Active context span:`, trace.getSpan(context.active())?.spanContext());
|
|
230
|
+
}
|
|
231
|
+
_logger.info("Tool started", {
|
|
232
|
+
runId,
|
|
233
|
+
parentRunId,
|
|
234
|
+
runName,
|
|
235
|
+
tags,
|
|
236
|
+
metadata,
|
|
237
|
+
input: input.substring(0, 200), // Truncate for logging
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
handleToolEnd: async (_output, runId, parentRunId, tags) => {
|
|
241
|
+
if (process.env.DEBUG_TELEMETRY === "true") {
|
|
242
|
+
console.log(`[handleToolEnd] runId=${runId}, parentRunId=${parentRunId}`);
|
|
243
|
+
}
|
|
244
|
+
_logger.info("Tool completed", {
|
|
245
|
+
runId,
|
|
246
|
+
parentRunId,
|
|
247
|
+
tags,
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
handleToolError: async (error, runId, parentRunId, tags) => {
|
|
251
|
+
if (process.env.DEBUG_TELEMETRY === "true") {
|
|
252
|
+
console.log(`[handleToolError] runId=${runId}, error=${error.message}`);
|
|
253
|
+
}
|
|
254
|
+
_logger.error("Tool failed", {
|
|
255
|
+
runId,
|
|
256
|
+
parentRunId,
|
|
257
|
+
tags,
|
|
258
|
+
error: error.message,
|
|
259
|
+
stack: error.stack,
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
// Build messages from context history if available, otherwise use just the prompt
|
|
264
|
+
let messages;
|
|
265
|
+
if (req.contextMessages && req.contextMessages.length > 0) {
|
|
266
|
+
// Use context messages (already resolved from context entries)
|
|
267
|
+
// Convert to LangChain format
|
|
268
|
+
messages = req.contextMessages.map((msg) => ({
|
|
269
|
+
type: msg.role === "user" ? "human" : "ai",
|
|
270
|
+
// Extract text from content blocks
|
|
271
|
+
content: msg.content
|
|
272
|
+
.filter((block) => block.type === "text")
|
|
273
|
+
.map((block) => block.text)
|
|
274
|
+
.join(""),
|
|
275
|
+
}));
|
|
276
|
+
// Add the current prompt as the final human message
|
|
277
|
+
const currentPromptText = req.prompt
|
|
278
|
+
.filter((promptMsg) => promptMsg.type === "text")
|
|
279
|
+
.map((promptMsg) => promptMsg.text)
|
|
280
|
+
.join("\n");
|
|
281
|
+
messages.push({
|
|
282
|
+
type: "human",
|
|
283
|
+
content: currentPromptText,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
113
286
|
else {
|
|
114
|
-
|
|
287
|
+
// Fallback: No context history, use just the prompt
|
|
288
|
+
messages = req.prompt
|
|
289
|
+
.filter((promptMsg) => promptMsg.type === "text")
|
|
290
|
+
.map((promptMsg) => ({
|
|
291
|
+
type: "human",
|
|
292
|
+
content: promptMsg.text,
|
|
293
|
+
}));
|
|
115
294
|
}
|
|
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,
|
|
295
|
+
// Create OTEL callbacks for instrumentation
|
|
296
|
+
const otelCallbacks = makeOtelCallbacks({
|
|
297
|
+
provider,
|
|
298
|
+
model: this.definition.model,
|
|
299
|
+
parentContext: invocationContext,
|
|
171
300
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
type: "human",
|
|
179
|
-
content: promptMsg.text,
|
|
301
|
+
// Create the stream within the invocation context so AsyncLocalStorage
|
|
302
|
+
// propagates the context to all tool executions and callbacks
|
|
303
|
+
const stream = context.with(invocationContext, () => agent.stream({ messages }, {
|
|
304
|
+
streamMode: ["updates", "messages"],
|
|
305
|
+
recursionLimit: 200,
|
|
306
|
+
callbacks: [loggingCallback, otelCallbacks],
|
|
180
307
|
}));
|
|
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
|
-
|
|
308
|
+
for await (const streamItem of await stream) {
|
|
309
|
+
const [streamMode, chunk] = streamItem;
|
|
310
|
+
if (streamMode === "updates") {
|
|
311
|
+
const updatesChunk = modelRequestSchema.safeParse(chunk);
|
|
312
|
+
if (!updatesChunk.success) {
|
|
313
|
+
// Other kinds of updates are either handled in the 'messages'
|
|
314
|
+
// streamMode (tool calls), or we don't care about them so far (not
|
|
315
|
+
// known yet).
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const updatesMessages = updatesChunk.data.model_request.messages;
|
|
319
|
+
if (!updatesMessages.every((m) => m instanceof AIMessageChunk)) {
|
|
320
|
+
throw new Error(`Unhandled updates message chunk types: ${JSON.stringify(updatesMessages)}`);
|
|
321
|
+
}
|
|
322
|
+
for (const msg of updatesMessages) {
|
|
323
|
+
// Extract token usage metadata if available
|
|
324
|
+
const tokenUsage = msg.usage_metadata
|
|
325
|
+
? {
|
|
326
|
+
inputTokens: msg.usage_metadata.input_tokens,
|
|
327
|
+
outputTokens: msg.usage_metadata.output_tokens,
|
|
328
|
+
totalTokens: msg.usage_metadata.total_tokens,
|
|
329
|
+
}
|
|
330
|
+
: undefined;
|
|
331
|
+
// Record token usage in telemetry
|
|
332
|
+
if (tokenUsage) {
|
|
333
|
+
telemetry.recordTokenUsage(tokenUsage.inputTokens ?? 0, tokenUsage.outputTokens ?? 0, invocationSpan);
|
|
334
|
+
}
|
|
335
|
+
// Accumulate token usage (deduplicate by message ID)
|
|
336
|
+
if (tokenUsage && msg.id && !countedMessageIds.has(msg.id)) {
|
|
337
|
+
turnTokenUsage.inputTokens += tokenUsage.inputTokens ?? 0;
|
|
338
|
+
turnTokenUsage.outputTokens += tokenUsage.outputTokens ?? 0;
|
|
339
|
+
turnTokenUsage.totalTokens += tokenUsage.totalTokens ?? 0;
|
|
340
|
+
countedMessageIds.add(msg.id);
|
|
206
341
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
342
|
+
for (const toolCall of msg.tool_calls ?? []) {
|
|
343
|
+
if (toolCall.id == null) {
|
|
344
|
+
throw new Error(`Tool call is missing id: ${JSON.stringify(toolCall)}`);
|
|
345
|
+
}
|
|
346
|
+
// Create tool span within the invocation context
|
|
347
|
+
// This makes the tool span a child of the invocation span
|
|
348
|
+
const toolSpan = context.with(invocationContext, () => telemetry.startSpan("agent.tool_call", {
|
|
349
|
+
"tool.name": toolCall.name,
|
|
350
|
+
"tool.id": toolCall.id,
|
|
351
|
+
}));
|
|
352
|
+
this.toolSpans.set(toolCall.id, toolSpan);
|
|
353
|
+
telemetry.log("info", `Tool call started: ${toolCall.name}`, {
|
|
354
|
+
toolCallId: toolCall.id,
|
|
355
|
+
toolName: toolCall.name,
|
|
356
|
+
toolArgs: JSON.stringify(toolCall.args),
|
|
357
|
+
});
|
|
358
|
+
// TODO: re-add this suppression of the todo_write tool call when we
|
|
359
|
+
// are rendering the agent-plan update in the UIs
|
|
360
|
+
// If this is a todo_write tool call, yield an agent-plan update
|
|
361
|
+
//if (toolCall.name === "todo_write" && toolCall.args?.todos) {
|
|
362
|
+
// const entries = toolCall.args.todos.flatMap((todo: unknown) => {
|
|
363
|
+
// const validation = todoItemSchema.safeParse(todo);
|
|
364
|
+
// if (!validation.success) {
|
|
365
|
+
// // Invalid todo - filter it out
|
|
366
|
+
// return [];
|
|
367
|
+
// }
|
|
368
|
+
// return [
|
|
369
|
+
// {
|
|
370
|
+
// content: validation.data.content,
|
|
371
|
+
// status: validation.data.status,
|
|
372
|
+
// priority: "medium" as const,
|
|
373
|
+
// },
|
|
374
|
+
// ];
|
|
375
|
+
// });
|
|
376
|
+
// yield {
|
|
377
|
+
// sessionUpdate: "plan",
|
|
378
|
+
// entries: entries,
|
|
379
|
+
// };
|
|
380
|
+
// // Track this tool call ID to suppress tool_call notifications
|
|
381
|
+
// todoWriteToolCallIds.add(toolCall.id);
|
|
382
|
+
// continue;
|
|
383
|
+
//}
|
|
384
|
+
const matchingTool = finalTools.find((t) => t.name === toolCall.name);
|
|
385
|
+
const prettyName = matchingTool?.prettyName;
|
|
386
|
+
const icon = matchingTool?.icon;
|
|
387
|
+
yield {
|
|
388
|
+
sessionUpdate: "tool_call",
|
|
389
|
+
toolCallId: toolCall.id,
|
|
390
|
+
title: toolCall.name,
|
|
391
|
+
kind: "other",
|
|
392
|
+
status: "pending",
|
|
393
|
+
rawInput: toolCall.args,
|
|
394
|
+
...(tokenUsage ? { tokenUsage } : {}),
|
|
395
|
+
_meta: {
|
|
396
|
+
messageId: req.messageId,
|
|
397
|
+
...(prettyName ? { prettyName } : {}),
|
|
398
|
+
...(icon ? { icon } : {}),
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
yield {
|
|
402
|
+
sessionUpdate: "tool_call_update",
|
|
403
|
+
toolCallId: toolCall.id,
|
|
404
|
+
status: "in_progress",
|
|
405
|
+
...(tokenUsage ? { tokenUsage } : {}),
|
|
406
|
+
_meta: { messageId: req.messageId },
|
|
407
|
+
};
|
|
211
408
|
}
|
|
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
409
|
}
|
|
263
410
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
411
|
+
else if (streamMode === "messages") {
|
|
412
|
+
const aiMessage = chunk[0];
|
|
413
|
+
if (aiMessage instanceof AIMessageChunk) {
|
|
414
|
+
// Extract token usage metadata if available
|
|
415
|
+
const messageTokenUsage = aiMessage.usage_metadata
|
|
416
|
+
? {
|
|
417
|
+
inputTokens: aiMessage.usage_metadata.input_tokens,
|
|
418
|
+
outputTokens: aiMessage.usage_metadata.output_tokens,
|
|
419
|
+
totalTokens: aiMessage.usage_metadata.total_tokens,
|
|
420
|
+
}
|
|
421
|
+
: undefined;
|
|
422
|
+
// Accumulate token usage (deduplicate by message ID)
|
|
423
|
+
if (messageTokenUsage &&
|
|
424
|
+
aiMessage.id &&
|
|
425
|
+
!countedMessageIds.has(aiMessage.id)) {
|
|
426
|
+
turnTokenUsage.inputTokens += messageTokenUsage.inputTokens ?? 0;
|
|
427
|
+
turnTokenUsage.outputTokens +=
|
|
428
|
+
messageTokenUsage.outputTokens ?? 0;
|
|
429
|
+
turnTokenUsage.totalTokens += messageTokenUsage.totalTokens ?? 0;
|
|
430
|
+
countedMessageIds.add(aiMessage.id);
|
|
274
431
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const contentLength = contentIsArray
|
|
280
|
-
? aiMessage.content.length
|
|
281
|
-
: typeof aiMessage.content === "string"
|
|
432
|
+
if (messageTokenUsage) {
|
|
433
|
+
const contentType = typeof aiMessage.content;
|
|
434
|
+
const contentIsArray = Array.isArray(aiMessage.content);
|
|
435
|
+
const contentLength = contentIsArray
|
|
282
436
|
? 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
|
-
? {
|
|
437
|
+
: typeof aiMessage.content === "string"
|
|
438
|
+
? aiMessage.content.length
|
|
439
|
+
: -1;
|
|
440
|
+
_logger.debug("messageTokenUsage", {
|
|
441
|
+
messageTokenUsage,
|
|
442
|
+
contentType,
|
|
443
|
+
isArray: contentIsArray,
|
|
444
|
+
length: contentLength,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
// If we have tokenUsage but no content, send a token-only chunk
|
|
448
|
+
if (messageTokenUsage &&
|
|
449
|
+
(typeof aiMessage.content === "string"
|
|
450
|
+
? aiMessage.content === ""
|
|
451
|
+
: Array.isArray(aiMessage.content) &&
|
|
452
|
+
aiMessage.content.length === 0)) {
|
|
453
|
+
_logger.debug("sending token-only chunk", {
|
|
454
|
+
messageTokenUsage,
|
|
455
|
+
});
|
|
456
|
+
const msgToYield = {
|
|
316
457
|
sessionUpdate: "agent_message_chunk",
|
|
317
458
|
content: {
|
|
318
459
|
type: "text",
|
|
319
|
-
text:
|
|
460
|
+
text: "", // Empty text, just carrying tokenUsage
|
|
320
461
|
},
|
|
321
462
|
_meta: {
|
|
322
463
|
tokenUsage: messageTokenUsage,
|
|
323
464
|
},
|
|
324
|
-
}
|
|
325
|
-
: {
|
|
326
|
-
sessionUpdate: "agent_message_chunk",
|
|
327
|
-
content: {
|
|
328
|
-
type: "text",
|
|
329
|
-
text: aiMessage.content,
|
|
330
|
-
},
|
|
331
465
|
};
|
|
332
|
-
|
|
466
|
+
yield msgToYield;
|
|
467
|
+
continue; // Skip the rest of the processing for this chunk
|
|
468
|
+
}
|
|
469
|
+
if (typeof aiMessage.content === "string") {
|
|
470
|
+
const msgToYield = messageTokenUsage
|
|
471
|
+
? {
|
|
472
|
+
sessionUpdate: "agent_message_chunk",
|
|
473
|
+
content: {
|
|
474
|
+
type: "text",
|
|
475
|
+
text: aiMessage.content,
|
|
476
|
+
},
|
|
477
|
+
_meta: {
|
|
478
|
+
tokenUsage: messageTokenUsage,
|
|
479
|
+
},
|
|
480
|
+
}
|
|
481
|
+
: {
|
|
482
|
+
sessionUpdate: "agent_message_chunk",
|
|
483
|
+
content: {
|
|
484
|
+
type: "text",
|
|
485
|
+
text: aiMessage.content,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
yield msgToYield;
|
|
489
|
+
}
|
|
490
|
+
else if (Array.isArray(aiMessage.content)) {
|
|
491
|
+
for (const part of aiMessage.content) {
|
|
492
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
493
|
+
const msgToYield = messageTokenUsage
|
|
494
|
+
? {
|
|
495
|
+
sessionUpdate: "agent_message_chunk",
|
|
496
|
+
content: {
|
|
497
|
+
type: "text",
|
|
498
|
+
text: part.text,
|
|
499
|
+
},
|
|
500
|
+
_meta: {
|
|
501
|
+
tokenUsage: messageTokenUsage,
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
: {
|
|
505
|
+
sessionUpdate: "agent_message_chunk",
|
|
506
|
+
content: {
|
|
507
|
+
type: "text",
|
|
508
|
+
text: part.text,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
yield msgToYield;
|
|
512
|
+
}
|
|
513
|
+
else if (part.type === "tool_use") {
|
|
514
|
+
// We don't care about tool use chunks -- do nothing
|
|
515
|
+
}
|
|
516
|
+
else if (part.type === "input_json_delta") {
|
|
517
|
+
// We don't care about tool use input delta chunks -- do nothing
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
throw new Error(`Unhandled AIMessageChunk content block type: ${part.type}\n${JSON.stringify(part)}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
throw new Error(`Unhandled AIMessageChunk content type: ${typeof aiMessage.content}`);
|
|
526
|
+
}
|
|
333
527
|
}
|
|
334
|
-
else if (
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
528
|
+
else if (aiMessage instanceof ToolMessage) {
|
|
529
|
+
if (typeof aiMessage.content === "string") {
|
|
530
|
+
if (todoWriteToolCallIds.has(aiMessage.tool_call_id)) {
|
|
531
|
+
// Skip tool_call_update for todo_write tools
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// End telemetry span for this tool call
|
|
535
|
+
const toolSpan = this.toolSpans.get(aiMessage.tool_call_id);
|
|
536
|
+
if (toolSpan) {
|
|
537
|
+
telemetry.log("info", "Tool call completed", {
|
|
538
|
+
toolCallId: aiMessage.tool_call_id,
|
|
539
|
+
});
|
|
540
|
+
telemetry.endSpan(toolSpan);
|
|
541
|
+
this.toolSpans.delete(aiMessage.tool_call_id);
|
|
542
|
+
}
|
|
543
|
+
// Send status update (metadata only, no content)
|
|
544
|
+
yield {
|
|
545
|
+
sessionUpdate: "tool_call_update",
|
|
546
|
+
toolCallId: aiMessage.tool_call_id,
|
|
547
|
+
status: "completed",
|
|
548
|
+
_meta: { messageId: req.messageId },
|
|
549
|
+
};
|
|
550
|
+
// Send tool output separately (via direct SSE, bypassing PostgreSQL NOTIFY)
|
|
551
|
+
yield {
|
|
552
|
+
sessionUpdate: "tool_output",
|
|
553
|
+
toolCallId: aiMessage.tool_call_id,
|
|
554
|
+
content: [
|
|
555
|
+
{
|
|
556
|
+
type: "content",
|
|
350
557
|
content: {
|
|
351
558
|
type: "text",
|
|
352
|
-
text:
|
|
559
|
+
text: aiMessage.content,
|
|
353
560
|
},
|
|
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
|
-
}
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
rawOutput: { content: aiMessage.content },
|
|
564
|
+
_meta: { messageId: req.messageId },
|
|
565
|
+
};
|
|
366
566
|
}
|
|
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;
|
|
567
|
+
else {
|
|
568
|
+
throw new Error(`Unhandled ToolMessage content type: ${typeof aiMessage.content}`);
|
|
377
569
|
}
|
|
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
570
|
}
|
|
402
571
|
else {
|
|
403
|
-
throw new Error(`Unhandled
|
|
572
|
+
throw new Error(`Unhandled message chunk type: ${JSON.stringify(aiMessage)}`);
|
|
404
573
|
}
|
|
405
574
|
}
|
|
406
575
|
else {
|
|
407
|
-
throw new Error(`Unhandled
|
|
576
|
+
throw new Error(`Unhandled stream mode: ${streamMode}`);
|
|
408
577
|
}
|
|
409
578
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
579
|
+
// Log successful completion
|
|
580
|
+
telemetry.log("info", "Agent invocation completed", {
|
|
581
|
+
sessionId: req.sessionId,
|
|
582
|
+
});
|
|
583
|
+
telemetry.endSpan(invocationSpan);
|
|
584
|
+
return {
|
|
585
|
+
stopReason: "end_turn",
|
|
586
|
+
_meta: {
|
|
587
|
+
tokenUsage: turnTokenUsage,
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
// Log error and end span with error status
|
|
593
|
+
telemetry.log("error", "Agent invocation failed", {
|
|
594
|
+
error: error instanceof Error ? error.message : String(error),
|
|
595
|
+
sessionId: req.sessionId,
|
|
596
|
+
});
|
|
597
|
+
telemetry.endSpan(invocationSpan, error instanceof Error ? error : new Error(String(error)));
|
|
598
|
+
throw error;
|
|
413
599
|
}
|
|
414
|
-
return {
|
|
415
|
-
stopReason: "end_turn",
|
|
416
|
-
};
|
|
417
600
|
}
|
|
418
601
|
}
|
|
419
602
|
const modelRequestSchema = z.object({
|