@gugacoder/agentic-sdk 0.2.0

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.
Files changed (129) hide show
  1. package/dist/agent.d.ts +2 -0
  2. package/dist/agent.js +463 -0
  3. package/dist/context/compaction.d.ts +27 -0
  4. package/dist/context/compaction.js +219 -0
  5. package/dist/context/models.d.ts +6 -0
  6. package/dist/context/models.js +41 -0
  7. package/dist/context/tokenizer.d.ts +5 -0
  8. package/dist/context/tokenizer.js +11 -0
  9. package/dist/context/usage.d.ts +11 -0
  10. package/dist/context/usage.js +49 -0
  11. package/dist/display-schemas.d.ts +1865 -0
  12. package/dist/display-schemas.js +219 -0
  13. package/dist/index.d.ts +38 -0
  14. package/dist/index.js +28 -0
  15. package/dist/middleware/logging.d.ts +2 -0
  16. package/dist/middleware/logging.js +32 -0
  17. package/dist/prompts/assembly.d.ts +13 -0
  18. package/dist/prompts/assembly.js +229 -0
  19. package/dist/providers.d.ts +19 -0
  20. package/dist/providers.js +44 -0
  21. package/dist/proxy.d.ts +2 -0
  22. package/dist/proxy.js +103 -0
  23. package/dist/schemas.d.ts +228 -0
  24. package/dist/schemas.js +51 -0
  25. package/dist/session.d.ts +7 -0
  26. package/dist/session.js +102 -0
  27. package/dist/structured.d.ts +18 -0
  28. package/dist/structured.js +38 -0
  29. package/dist/tool-repair.d.ts +21 -0
  30. package/dist/tool-repair.js +72 -0
  31. package/dist/tools/api-spec.d.ts +4 -0
  32. package/dist/tools/api-spec.js +123 -0
  33. package/dist/tools/apply-patch.d.ts +484 -0
  34. package/dist/tools/apply-patch.js +157 -0
  35. package/dist/tools/ask-user.d.ts +14 -0
  36. package/dist/tools/ask-user.js +27 -0
  37. package/dist/tools/bash.d.ts +550 -0
  38. package/dist/tools/bash.js +43 -0
  39. package/dist/tools/batch.d.ts +13 -0
  40. package/dist/tools/batch.js +84 -0
  41. package/dist/tools/brave-search.d.ts +6 -0
  42. package/dist/tools/brave-search.js +19 -0
  43. package/dist/tools/code-search.d.ts +20 -0
  44. package/dist/tools/code-search.js +42 -0
  45. package/dist/tools/diagnostics.d.ts +4 -0
  46. package/dist/tools/diagnostics.js +69 -0
  47. package/dist/tools/display.d.ts +483 -0
  48. package/dist/tools/display.js +77 -0
  49. package/dist/tools/edit.d.ts +682 -0
  50. package/dist/tools/edit.js +47 -0
  51. package/dist/tools/glob.d.ts +4 -0
  52. package/dist/tools/glob.js +42 -0
  53. package/dist/tools/grep.d.ts +6 -0
  54. package/dist/tools/grep.js +69 -0
  55. package/dist/tools/http-request.d.ts +7 -0
  56. package/dist/tools/http-request.js +98 -0
  57. package/dist/tools/index.d.ts +1611 -0
  58. package/dist/tools/index.js +46 -0
  59. package/dist/tools/job-tools.d.ts +24 -0
  60. package/dist/tools/job-tools.js +67 -0
  61. package/dist/tools/list-dir.d.ts +5 -0
  62. package/dist/tools/list-dir.js +79 -0
  63. package/dist/tools/multi-edit.d.ts +814 -0
  64. package/dist/tools/multi-edit.js +57 -0
  65. package/dist/tools/read.d.ts +5 -0
  66. package/dist/tools/read.js +33 -0
  67. package/dist/tools/task.d.ts +21 -0
  68. package/dist/tools/task.js +51 -0
  69. package/dist/tools/todo.d.ts +14 -0
  70. package/dist/tools/todo.js +60 -0
  71. package/dist/tools/web-fetch.d.ts +4 -0
  72. package/dist/tools/web-fetch.js +126 -0
  73. package/dist/tools/web-search.d.ts +22 -0
  74. package/dist/tools/web-search.js +48 -0
  75. package/dist/tools/write.d.ts +550 -0
  76. package/dist/tools/write.js +30 -0
  77. package/dist/types.d.ts +201 -0
  78. package/dist/types.js +1 -0
  79. package/package.json +43 -0
  80. package/src/agent.ts +520 -0
  81. package/src/context/compaction.ts +265 -0
  82. package/src/context/models.ts +42 -0
  83. package/src/context/tokenizer.ts +12 -0
  84. package/src/context/usage.ts +65 -0
  85. package/src/display-schemas.ts +276 -0
  86. package/src/index.ts +43 -0
  87. package/src/middleware/logging.ts +37 -0
  88. package/src/prompts/assembly.ts +263 -0
  89. package/src/prompts/identity.md +10 -0
  90. package/src/prompts/patterns.md +7 -0
  91. package/src/prompts/safety.md +7 -0
  92. package/src/prompts/tool-guide.md +9 -0
  93. package/src/prompts/tools/bash.md +7 -0
  94. package/src/prompts/tools/edit.md +7 -0
  95. package/src/prompts/tools/glob.md +7 -0
  96. package/src/prompts/tools/grep.md +7 -0
  97. package/src/prompts/tools/read.md +7 -0
  98. package/src/prompts/tools/write.md +7 -0
  99. package/src/providers.ts +58 -0
  100. package/src/proxy.ts +101 -0
  101. package/src/schemas.ts +58 -0
  102. package/src/session.ts +110 -0
  103. package/src/structured.ts +65 -0
  104. package/src/tool-repair.ts +92 -0
  105. package/src/tools/api-spec.ts +158 -0
  106. package/src/tools/apply-patch.ts +188 -0
  107. package/src/tools/ask-user.ts +40 -0
  108. package/src/tools/bash.ts +51 -0
  109. package/src/tools/batch.ts +103 -0
  110. package/src/tools/brave-search.ts +24 -0
  111. package/src/tools/code-search.ts +69 -0
  112. package/src/tools/diagnostics.ts +93 -0
  113. package/src/tools/display.ts +105 -0
  114. package/src/tools/edit.ts +55 -0
  115. package/src/tools/glob.ts +46 -0
  116. package/src/tools/grep.ts +68 -0
  117. package/src/tools/http-request.ts +103 -0
  118. package/src/tools/index.ts +48 -0
  119. package/src/tools/job-tools.ts +84 -0
  120. package/src/tools/list-dir.ts +102 -0
  121. package/src/tools/multi-edit.ts +65 -0
  122. package/src/tools/read.ts +40 -0
  123. package/src/tools/task.ts +71 -0
  124. package/src/tools/todo.ts +82 -0
  125. package/src/tools/web-fetch.ts +155 -0
  126. package/src/tools/web-search.ts +75 -0
  127. package/src/tools/write.ts +34 -0
  128. package/src/types.ts +145 -0
  129. package/tsconfig.json +17 -0
package/src/agent.ts ADDED
@@ -0,0 +1,520 @@
1
+ import { streamText, stepCountIs, wrapLanguageModel, type ModelMessage, type TelemetrySettings } from "ai";
2
+ import { createAiProviderRegistry } from "./providers.js";
3
+ import { createMCPClient, type MCPClient } from "@ai-sdk/mcp";
4
+ import { Experimental_StdioMCPTransport as StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
5
+ import { codingTools } from "./tools/index.js";
6
+ import { createDisplayTools } from "./tools/display.js";
7
+ import { createBashTool } from "./tools/bash.js";
8
+ import { createWriteTool } from "./tools/write.js";
9
+ import { createEditTool } from "./tools/edit.js";
10
+ import { createMultiEditTool } from "./tools/multi-edit.js";
11
+ import { createApplyPatchTool } from "./tools/apply-patch.js";
12
+ import { createAskUserTool } from "./tools/ask-user.js";
13
+ import { createWebSearchTool } from "./tools/web-search.js";
14
+ import { createTaskTool } from "./tools/task.js";
15
+ import { createBatchTool } from "./tools/batch.js";
16
+ import { createCodeSearchTool } from "./tools/code-search.js";
17
+ import { loadSession, saveSession, filterOldMedia } from "./session.js";
18
+ import { getSystemPrompt, discoverProjectContext } from "./prompts/assembly.js";
19
+ import { getContextUsage } from "./context/usage.js";
20
+ import { compactMessages } from "./context/compaction.js";
21
+ import { createToolCallRepairHandler } from "./tool-repair.js";
22
+ import type { AiAgentEvent, AiAgentOptions, McpServerConfig, PrepareStepResult } from "./types.js";
23
+ import { randomUUID } from "node:crypto";
24
+ import { join } from "node:path";
25
+
26
+ const DEFAULT_SESSION_DIR = join(process.cwd(), "data", "ai-sessions");
27
+ const DEFAULT_MAX_STEPS = 30;
28
+
29
+ /**
30
+ * Attempt to create an OTel span for the agent run.
31
+ * Uses dynamic import so the build doesn't break when @opentelemetry/api isn't installed.
32
+ * Returns a Span-like object with .end(), or undefined when unavailable/disabled.
33
+ */
34
+ async function tryStartSessionSpan(
35
+ options: AiAgentOptions,
36
+ sessionId: string
37
+ ): Promise<{ end: () => void } | undefined> {
38
+ if (!options.telemetry?.enabled) return undefined;
39
+
40
+ try {
41
+ const otel = await import("@opentelemetry/api");
42
+ const tracer = otel.trace.getTracer("ai-sdk");
43
+ return tracer.startSpan("ai.agent.run", {
44
+ attributes: {
45
+ "ai.session_id": sessionId,
46
+ "ai.model": options.model,
47
+ "ai.max_steps": options.maxSteps ?? DEFAULT_MAX_STEPS,
48
+ },
49
+ });
50
+ } catch {
51
+ console.warn(
52
+ "[ai] telemetry.enabled is true but @opentelemetry/api is not installed. " +
53
+ "Install it as a dependency to enable custom spans. Continuing without session span."
54
+ );
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ function createMcpTransport(config: McpServerConfig) {
60
+ if (config.transport.type === "stdio") {
61
+ return new StdioMCPTransport({
62
+ command: config.transport.command,
63
+ args: config.transport.args,
64
+ });
65
+ }
66
+ // http transport — passed directly to createMCPClient
67
+ return {
68
+ type: config.transport.type as "http",
69
+ url: config.transport.url,
70
+ headers: config.transport.headers,
71
+ };
72
+ }
73
+
74
+ export async function* runAiAgent(
75
+ prompt: string,
76
+ options: AiAgentOptions
77
+ ): AsyncGenerator<AiAgentEvent> {
78
+ const providers = createAiProviderRegistry({
79
+ apiKey: options.apiKey,
80
+ aliases: options.modelAliases,
81
+ providers: options.providers,
82
+ });
83
+
84
+ const sessionDir = options.sessionDir ?? DEFAULT_SESSION_DIR;
85
+ const sessionId = options.sessionId ?? randomUUID();
86
+
87
+ // Session resume: load history if sessionDir was provided
88
+ let previousMessages: ModelMessage[] = [];
89
+ if (options.sessionDir) {
90
+ previousMessages = await loadSession(sessionDir);
91
+ }
92
+
93
+ const startMs = Date.now();
94
+ const userMsg: any = { role: "user", content: options.contentParts ?? prompt };
95
+ if (options.messageMeta) {
96
+ userMsg._meta = { ts: new Date().toISOString(), ...options.messageMeta };
97
+ }
98
+ const messages: ModelMessage[] = [
99
+ ...previousMessages,
100
+ userMsg,
101
+ ];
102
+
103
+ yield { type: "init", sessionId };
104
+
105
+ // Session-level OTel span (F-006): wraps the entire agent run
106
+ const sessionSpan = await tryStartSessionSpan(options, sessionId);
107
+
108
+ // MCP client lifecycle: create clients, collect tools, close on exit
109
+ const mcpClients: MCPClient[] = [];
110
+
111
+ try {
112
+ // Connect to MCP servers and collect their tools
113
+ let mcpTools: Record<string, any> = {};
114
+ if (options.mcpServers && options.mcpServers.length > 0) {
115
+ const connectedServers: string[] = [];
116
+ for (const serverConfig of options.mcpServers) {
117
+ try {
118
+ const transport = createMcpTransport(serverConfig);
119
+ const client = await createMCPClient({
120
+ transport,
121
+ name: serverConfig.name,
122
+ });
123
+ mcpClients.push(client);
124
+ const serverTools = await client.tools();
125
+ mcpTools = { ...mcpTools, ...serverTools };
126
+ connectedServers.push(serverConfig.name);
127
+ } catch (err) {
128
+ const msg = err instanceof Error ? err.message : String(err);
129
+ console.warn(`[ai] MCP server "${serverConfig.name}" failed to connect: ${msg}`);
130
+ yield { type: "mcp_error" as const, server: serverConfig.name, error: msg };
131
+ }
132
+ }
133
+ yield { type: "mcp_connected", servers: connectedServers };
134
+ }
135
+
136
+ // Override pluggable tools with configured callbacks if provided
137
+ // codingTools have priority over MCP tools (spread order: MCP first, coding on top)
138
+ const dangerousTools = {
139
+ Bash: createBashTool(),
140
+ Write: createWriteTool(),
141
+ Edit: createEditTool(),
142
+ MultiEdit: createMultiEditTool(),
143
+ ApplyPatch: createApplyPatchTool(),
144
+ };
145
+ const displayTools = options.disableDisplayTools ? {} : createDisplayTools();
146
+ let tools = { ...mcpTools, ...codingTools, ...displayTools, ...dangerousTools };
147
+ if (options.tools) {
148
+ tools = { ...tools, ...options.tools };
149
+ }
150
+ if (options.onAskUser) {
151
+ tools = { ...tools, AskUser: createAskUserTool(options.onAskUser) };
152
+ }
153
+ if (options.onWebSearch) {
154
+ tools = { ...tools, WebSearch: createWebSearchTool(options.onWebSearch) };
155
+ }
156
+ if (options.onCodeSearch) {
157
+ tools = { ...tools, CodeSearch: createCodeSearchTool(options.onCodeSearch) };
158
+ }
159
+
160
+ // Always override Task tool with parent's config so sub-agents inherit model/apiKey
161
+ tools = {
162
+ ...tools,
163
+ Task: createTaskTool({
164
+ model: options.model,
165
+ apiKey: options.apiKey,
166
+ maxSubSteps: Math.min(options.maxSteps ?? DEFAULT_MAX_STEPS, 10),
167
+ }),
168
+ };
169
+
170
+ // Override Batch tool with the fully resolved tool registry
171
+ tools = {
172
+ ...tools,
173
+ Batch: createBatchTool(tools),
174
+ };
175
+
176
+ // Build system prompt based on 3 modes:
177
+ // 1. undefined → auto: base prompt (filtered by active tools) + project context
178
+ // 2. { append } → base prompt + project context + consumer's append text
179
+ // 3. string → override: consumer's string only, no base, no discovery
180
+ let systemPrompt: string | undefined;
181
+
182
+ if (typeof options.system === "string") {
183
+ // Mode 3: full override — consumer replaces everything
184
+ systemPrompt = options.system;
185
+ } else {
186
+ // Mode 1 or 2: build base prompt from active tools
187
+ const activeTools = Object.keys(tools);
188
+ const base = getSystemPrompt(activeTools);
189
+
190
+ // Discover project context once (AGENTS.md / CLAUDE.md walk-up)
191
+ const cwd = options.cwd ?? process.cwd();
192
+ const projectContext = await discoverProjectContext(cwd);
193
+
194
+ const parts = [base];
195
+ if (projectContext) {
196
+ parts.push(projectContext);
197
+ }
198
+
199
+ // Mode 2: append consumer text after base + context
200
+ if (options.system && typeof options.system === "object" && "append" in options.system) {
201
+ parts.push(options.system.append);
202
+ }
203
+
204
+ systemPrompt = parts.join("\n\n");
205
+ }
206
+
207
+ // --- Context management: usage calculation + compaction ---
208
+ const toolDefinitions = tools as Record<string, unknown>;
209
+ let compacted = false;
210
+
211
+ // Calculate context usage before potential compaction
212
+ let ctxUsage = getContextUsage({
213
+ model: options.model,
214
+ systemPrompt: systemPrompt ?? "",
215
+ toolDefinitions,
216
+ messages,
217
+ contextWindow: options.contextWindow,
218
+ compactThreshold: options.compactThreshold,
219
+ });
220
+
221
+ // Compact if threshold exceeded and compaction is enabled
222
+ if (ctxUsage.willCompact && !options.disableCompaction) {
223
+ const compactResult = await compactMessages(messages, {
224
+ model: options.model,
225
+ apiKey: options.apiKey,
226
+ contextWindow: options.contextWindow,
227
+ systemPromptTokens: ctxUsage.systemPrompt,
228
+ toolDefinitionsTokens: ctxUsage.toolDefinitions,
229
+ middleware: options.middleware,
230
+ telemetry: options.telemetry,
231
+ providers,
232
+ });
233
+
234
+ if (compactResult.compacted) {
235
+ messages.length = 0;
236
+ messages.push(...compactResult.messages);
237
+ compacted = true;
238
+
239
+ // Recalculate usage after compaction
240
+ ctxUsage = getContextUsage({
241
+ model: options.model,
242
+ systemPrompt: systemPrompt ?? "",
243
+ toolDefinitions,
244
+ messages,
245
+ contextWindow: options.contextWindow,
246
+ compactThreshold: options.compactThreshold,
247
+ });
248
+ }
249
+
250
+ if (compactResult.warning) {
251
+ console.warn(`[ai] ${compactResult.warning}`);
252
+ }
253
+ }
254
+
255
+ // Emit context_status event before calling streamText
256
+ yield {
257
+ type: "context_status",
258
+ context: { ...ctxUsage, compacted },
259
+ };
260
+
261
+ // Step event tracking — onStepFinish pushes events, fullStream loop yields them
262
+ const pendingStepEvents: AiAgentEvent[] = [];
263
+ let stepCounter = 0;
264
+ let previousToolCalls: string[] = [];
265
+
266
+ // Step data collection for telemetry (F-007)
267
+ const telemetryEnabled = options.telemetry?.enabled === true;
268
+ const collectedSteps: Array<{
269
+ stepNumber: number;
270
+ toolCalls: string[];
271
+ inputTokens: number;
272
+ outputTokens: number;
273
+ durationMs: number;
274
+ }> = [];
275
+ let stepStartMs = Date.now();
276
+
277
+ // Tool repair: delegate to SDK's experimental_repairToolCall
278
+ const experimentalRepairToolCall = (options.repairToolCalls !== false)
279
+ ? createToolCallRepairHandler({
280
+ model: providers.model(options.model, options.provider),
281
+ maxAttempts: options.maxRepairAttempts ?? 1,
282
+ })
283
+ : undefined;
284
+
285
+ // prepareStep integration — call consumer's callback before streamText to get initial overrides
286
+ let stepModel = providers.model(options.model, options.provider);
287
+ let stepActiveTools: string[] | undefined;
288
+ let stepToolChoice: PrepareStepResult["toolChoice"] = undefined;
289
+
290
+ if (options.prepareStep) {
291
+ const overrides = options.prepareStep({
292
+ stepNumber: 0,
293
+ stepCount: 0,
294
+ previousToolCalls: [],
295
+ });
296
+ if (overrides) {
297
+ if (overrides.model) {
298
+ stepModel = providers.model(overrides.model);
299
+ }
300
+ if (overrides.activeTools) {
301
+ stepActiveTools = overrides.activeTools;
302
+ }
303
+ if (overrides.toolChoice) {
304
+ stepToolChoice = overrides.toolChoice;
305
+ }
306
+ }
307
+ }
308
+
309
+ // Apply middleware pipeline to the model if provided
310
+ const effectiveModel =
311
+ options.middleware && options.middleware.length > 0
312
+ ? wrapLanguageModel({ model: stepModel, middleware: options.middleware })
313
+ : stepModel;
314
+
315
+ // Build telemetry config for Vercel AI SDK when enabled
316
+ const telemetryConfig: TelemetrySettings | undefined =
317
+ options.telemetry?.enabled
318
+ ? {
319
+ isEnabled: true,
320
+ functionId: options.telemetry.functionId ?? "ai-agent",
321
+ recordInputs: false,
322
+ recordOutputs: false,
323
+ metadata: {
324
+ sessionId,
325
+ model: options.model,
326
+ ...options.telemetry.metadata,
327
+ },
328
+ }
329
+ : undefined;
330
+
331
+ // stopWhen integration — AbortController to cancel the stream when condition is met
332
+ const abortController = options.stopWhen ? new AbortController() : undefined;
333
+ let stoppedByStopWhen = false;
334
+
335
+ // providerOptions for extended thinking/reasoning (F-171)
336
+ const reasoningConfig = options.reasoning
337
+ ? {
338
+ anthropic: {
339
+ thinking: {
340
+ type: "enabled" as const,
341
+ budgetTokens: typeof options.reasoning === "object"
342
+ ? options.reasoning.budgetTokens
343
+ : 5000,
344
+ },
345
+ },
346
+ }
347
+ : undefined;
348
+
349
+ const callStreamText = () =>
350
+ streamText({
351
+ model: effectiveModel,
352
+ tools,
353
+ maxRetries: 3,
354
+ stopWhen: stepCountIs(options.maxSteps ?? DEFAULT_MAX_STEPS),
355
+ messages: filterOldMedia(messages, messages.findLastIndex((m) => m.role === "user")),
356
+ system: systemPrompt,
357
+ ...(telemetryConfig ? { experimental_telemetry: telemetryConfig } : {}),
358
+ ...(stepActiveTools ? { activeTools: stepActiveTools as any } : {}),
359
+ ...(stepToolChoice ? { toolChoice: stepToolChoice as any } : {}),
360
+ ...(abortController ? { abortSignal: abortController.signal } : {}),
361
+ ...(experimentalRepairToolCall ? { experimental_repairToolCall: experimentalRepairToolCall } : {}),
362
+ ...(reasoningConfig ? { providerOptions: reasoningConfig } : {}),
363
+ onStepFinish: (stepResult) => {
364
+ const toolNames = (stepResult.toolCalls ?? []).map(
365
+ (tc: { toolName: string }) => tc.toolName
366
+ );
367
+ const stepEvent = {
368
+ type: "step_finish" as const,
369
+ step: stepCounter,
370
+ toolCalls: toolNames,
371
+ finishReason: stepResult.finishReason ?? "unknown",
372
+ };
373
+ pendingStepEvents.push(stepEvent);
374
+
375
+ // Collect per-step breakdown when telemetry is enabled (F-007)
376
+ if (telemetryEnabled) {
377
+ const now = Date.now();
378
+ collectedSteps.push({
379
+ stepNumber: stepCounter,
380
+ toolCalls: toolNames,
381
+ inputTokens: (stepResult.usage as any)?.inputTokens ?? (stepResult.usage as any)?.promptTokens ?? 0,
382
+ outputTokens: (stepResult.usage as any)?.outputTokens ?? (stepResult.usage as any)?.completionTokens ?? 0,
383
+ durationMs: now - stepStartMs,
384
+ });
385
+ stepStartMs = now;
386
+ }
387
+
388
+ previousToolCalls = toolNames;
389
+ stepCounter++;
390
+
391
+ // Evaluate stopWhen condition after recording the step event
392
+ if (options.stopWhen && options.stopWhen(stepEvent)) {
393
+ stoppedByStopWhen = true;
394
+ abortController!.abort();
395
+ }
396
+ },
397
+ });
398
+
399
+ const result = callStreamText();
400
+
401
+ // Stream text deltas — surface API errors instead of hanging
402
+ let fullText = "";
403
+ try {
404
+ for await (const part of result.fullStream) {
405
+ if (part.type === "text-delta") {
406
+ const delta = (part as any).text ?? (part as any).delta ?? (part as any).textDelta ?? "";
407
+ fullText += delta;
408
+ yield { type: "text", content: delta };
409
+ } else if (part.type === "finish-step") {
410
+ // Drain pending step_finish events collected by onStepFinish
411
+ while (pendingStepEvents.length > 0) {
412
+ yield pendingStepEvents.shift()!;
413
+ }
414
+ } else if ((part as any).type === "reasoning" || part.type === "reasoning-delta") {
415
+ yield {
416
+ type: "reasoning",
417
+ content: (part as any).delta ?? (part as any).textDelta ?? (part as any).text ?? "",
418
+ };
419
+ } else if (part.type === "tool-call") {
420
+ yield {
421
+ type: "tool-call",
422
+ toolCallId: (part as any).toolCallId,
423
+ toolName: (part as any).toolName,
424
+ args: (part as any).input ?? (part as any).args ?? {},
425
+ };
426
+ } else if (part.type === "tool-result") {
427
+ yield {
428
+ type: "tool-result",
429
+ toolCallId: (part as any).toolCallId,
430
+ toolName: (part as any).toolName,
431
+ result: (part as any).output ?? (part as any).result,
432
+ };
433
+ } else if (part.type === "error") {
434
+ const errMsg = (part as any).error?.message ?? JSON.stringify((part as any).error ?? part);
435
+ throw new Error(`OpenRouter API error: ${errMsg}`);
436
+ }
437
+ }
438
+ } catch (err) {
439
+ // If stopWhen triggered the abort, this is expected — continue normally
440
+ if (stoppedByStopWhen && err instanceof Error && err.name === "AbortError") {
441
+ // Expected abort — fall through to drain remaining events and finalize
442
+ } else {
443
+ // Re-throw with context if it's not already our error
444
+ const msg = err instanceof Error ? err.message : String(err);
445
+ if (!msg.startsWith("OpenRouter")) {
446
+ throw new Error(`OpenRouter stream error: ${msg}`);
447
+ }
448
+ throw err;
449
+ }
450
+ }
451
+
452
+ // Drain any remaining step events not yet emitted (edge case: stream ends without step-finish)
453
+ while (pendingStepEvents.length > 0) {
454
+ yield pendingStepEvents.shift()!;
455
+ }
456
+
457
+ // Persist session and collect usage — may fail if stream was aborted by stopWhen
458
+ let usage: { inputTokens?: number; outputTokens?: number; promptTokens?: number; completionTokens?: number } = {};
459
+ let steps: unknown[] = [];
460
+ let finishReason: string = stoppedByStopWhen ? "stop_when" : "unknown";
461
+ let totalCostUsd = 0;
462
+
463
+ try {
464
+ const response = await result.response;
465
+ const responseMsgs = (response.messages as any[]).map((m) => ({
466
+ ...m,
467
+ _meta: { ts: new Date().toISOString() },
468
+ }));
469
+ await saveSession(sessionDir, [...messages, ...responseMsgs]);
470
+
471
+ usage = await result.totalUsage;
472
+ steps = await result.steps;
473
+ finishReason = stoppedByStopWhen ? "stop_when" : (await result.finishReason ?? "unknown");
474
+
475
+ // Fetch cost from OpenRouter generation endpoint
476
+ const genId = response.id;
477
+ if (genId) {
478
+ // Small delay — OpenRouter may not have the generation ready immediately
479
+ await new Promise((r) => setTimeout(r, 500));
480
+ const res = await fetch(`https://openrouter.ai/api/v1/generation?id=${genId}`, {
481
+ headers: { Authorization: `Bearer ${options.apiKey}` },
482
+ });
483
+ if (res.ok) {
484
+ const gen = (await res.json()) as { data?: { total_cost?: number } };
485
+ totalCostUsd = gen.data?.total_cost ?? 0;
486
+ }
487
+ }
488
+ } catch {
489
+ // If stopWhen aborted the stream, response/usage promises may reject — use defaults
490
+ }
491
+
492
+ yield { type: "result", content: fullText };
493
+ yield {
494
+ type: "usage",
495
+ usage: {
496
+ inputTokens: usage.inputTokens ?? usage.promptTokens ?? 0,
497
+ outputTokens: usage.outputTokens ?? usage.completionTokens ?? 0,
498
+ cacheReadInputTokens: 0,
499
+ cacheCreationInputTokens: 0,
500
+ totalCostUsd,
501
+ numTurns: steps.length,
502
+ durationMs: Date.now() - startMs,
503
+ durationApiMs: 0,
504
+ stopReason: finishReason,
505
+ // Step breakdown — only populated when telemetry is enabled (F-007)
506
+ ...(telemetryEnabled && collectedSteps.length > 0
507
+ ? { steps: collectedSteps }
508
+ : {}),
509
+ },
510
+ };
511
+ } finally {
512
+ // End session span before cleanup (F-006)
513
+ sessionSpan?.end();
514
+
515
+ // Close all MCP clients to avoid leaked connections/processes
516
+ for (const client of mcpClients) {
517
+ await client.close().catch(() => {});
518
+ }
519
+ }
520
+ }