wave-agent-sdk 0.18.5 → 0.18.7

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 (60) hide show
  1. package/dist/managers/aiManager.d.ts.map +1 -1
  2. package/dist/managers/aiManager.js +92 -87
  3. package/dist/managers/cronManager.d.ts.map +1 -1
  4. package/dist/managers/cronManager.js +2 -0
  5. package/dist/managers/liveConfigManager.d.ts.map +1 -1
  6. package/dist/managers/liveConfigManager.js +0 -9
  7. package/dist/managers/mcpManager.d.ts.map +1 -1
  8. package/dist/managers/mcpManager.js +6 -1
  9. package/dist/managers/messageManager.d.ts +1 -1
  10. package/dist/managers/messageManager.d.ts.map +1 -1
  11. package/dist/managers/messageManager.js +10 -4
  12. package/dist/managers/toolManager.d.ts.map +1 -1
  13. package/dist/managers/toolManager.js +2 -0
  14. package/dist/services/configurationService.d.ts.map +1 -1
  15. package/dist/services/configurationService.js +2 -0
  16. package/dist/telemetry/sessionTracing.d.ts +17 -16
  17. package/dist/telemetry/sessionTracing.d.ts.map +1 -1
  18. package/dist/telemetry/sessionTracing.js +62 -58
  19. package/dist/tools/readTool.d.ts.map +1 -1
  20. package/dist/tools/readTool.js +6 -7
  21. package/dist/types/telemetry.d.ts +2 -0
  22. package/dist/types/telemetry.d.ts.map +1 -1
  23. package/dist/utils/fileUtils.d.ts +0 -6
  24. package/dist/utils/fileUtils.d.ts.map +1 -1
  25. package/dist/utils/fileUtils.js +0 -43
  26. package/dist/utils/gitUtils.d.ts +11 -0
  27. package/dist/utils/gitUtils.d.ts.map +1 -1
  28. package/dist/utils/gitUtils.js +51 -0
  29. package/dist/utils/groupMessagesByApiRound.d.ts.map +1 -1
  30. package/dist/utils/groupMessagesByApiRound.js +7 -6
  31. package/dist/utils/openaiClient.d.ts.map +1 -1
  32. package/dist/utils/openaiClient.js +2 -0
  33. package/dist/utils/tokenEstimate.d.ts +24 -0
  34. package/dist/utils/tokenEstimate.d.ts.map +1 -0
  35. package/dist/utils/tokenEstimate.js +30 -0
  36. package/dist/utils/worktreeUtils.d.ts.map +1 -1
  37. package/dist/utils/worktreeUtils.js +3 -1
  38. package/package.json +1 -1
  39. package/scripts/install_ripgrep.js +1 -21
  40. package/src/managers/aiManager.ts +111 -102
  41. package/src/managers/cronManager.ts +2 -0
  42. package/src/managers/liveConfigManager.ts +0 -12
  43. package/src/managers/mcpManager.ts +8 -1
  44. package/src/managers/messageManager.ts +10 -5
  45. package/src/managers/toolManager.ts +2 -0
  46. package/src/services/configurationService.ts +2 -0
  47. package/src/telemetry/sessionTracing.ts +64 -67
  48. package/src/tools/readTool.ts +6 -12
  49. package/src/types/telemetry.ts +2 -0
  50. package/src/utils/fileUtils.ts +0 -46
  51. package/src/utils/gitUtils.ts +54 -0
  52. package/src/utils/groupMessagesByApiRound.ts +7 -6
  53. package/src/utils/openaiClient.ts +2 -0
  54. package/src/utils/tokenEstimate.ts +34 -0
  55. package/src/utils/worktreeUtils.ts +8 -1
  56. package/vendor/ripgrep/linux-aarch64/rg +0 -0
  57. package/vendor/ripgrep/macos-aarch64/rg +0 -0
  58. package/vendor/ripgrep/macos-x86_64/rg +0 -0
  59. package/vendor/ripgrep/windows-aarch64/rg.exe +0 -0
  60. package/vendor/ripgrep/windows-x86_64/rg.exe +0 -0
@@ -3,6 +3,7 @@ import * as aiService from "../services/aiService.js";
3
3
  import { convertMessagesForAPI } from "../utils/convertMessagesForAPI.js";
4
4
  import { parseTaskNotificationXml } from "../utils/notificationXml.js";
5
5
  import { calculateComprehensiveTotalTokens } from "../utils/tokenCalculation.js";
6
+ import { estimateTokens } from "../utils/tokenEstimate.js";
6
7
  import {
7
8
  getTaskReminderTurnCounts,
8
9
  maybeInjectTaskReminder,
@@ -606,7 +607,7 @@ export class AIManager {
606
607
  );
607
608
  let usedTokens = 0;
608
609
  for (const file of recentFiles) {
609
- const fileTokens = Math.ceil(file.content.length / 4);
610
+ const fileTokens = estimateTokens(file.content);
610
611
  if (usedTokens + fileTokens > POST_COMPACT_MAX_TOKENS_PER_FILE) continue;
611
612
  if (fileTokens > 0) usedTokens += fileTokens;
612
613
  contextParts.push(`\n\n## ${file.path}\n\`\`\`\n${file.content}\n\`\`\``);
@@ -639,7 +640,7 @@ export class AIManager {
639
640
  skillContent.slice(0, maxSkillChars) + "\n\n...[truncated]...";
640
641
  }
641
642
 
642
- const skillTokens = Math.ceil(skillContent.length / 4);
643
+ const skillTokens = estimateTokens(skillContent);
643
644
  if (skillsUsedTokens + skillTokens > POST_COMPACT_SKILLS_TOKEN_BUDGET)
644
645
  break;
645
646
  skillsUsedTokens += skillTokens;
@@ -813,6 +814,7 @@ export class AIManager {
813
814
  let turnDepth = turnOffset;
814
815
 
815
816
  inner: while (true) {
817
+ let llmSpan: import("@opentelemetry/api").Span | undefined;
816
818
  try {
817
819
  // Save session in each iteration to ensure message persistence
818
820
  await this.messageManager.saveSession();
@@ -949,15 +951,21 @@ export class AIManager {
949
951
  };
950
952
  }
951
953
 
952
- startLLMRequestSpan(model || this.getModelConfig().model || "");
954
+ llmSpan = startLLMRequestSpan(
955
+ model || this.getModelConfig().model || "",
956
+ );
953
957
 
954
958
  const result = await aiService.callAgent(callAgentOptions);
955
959
 
956
960
  // End LLM span with usage data
957
- endLLMRequestSpan({
961
+ endLLMRequestSpan(llmSpan, {
958
962
  model: model || this.getModelConfig().model || "",
959
963
  success: true,
960
964
  hasToolCall: !!(result.tool_calls && result.tool_calls.length > 0),
965
+ inputTokens: result.usage?.prompt_tokens,
966
+ outputTokens: result.usage?.completion_tokens,
967
+ cacheReadTokens: result.usage?.cache_read_input_tokens,
968
+ cacheCreationTokens: result.usage?.cache_creation_input_tokens,
961
969
  });
962
970
 
963
971
  const createdByStreaming = assistantMessageCreated;
@@ -1238,7 +1246,7 @@ export class AIManager {
1238
1246
  break inner;
1239
1247
  } catch (error) {
1240
1248
  // End LLM span with error
1241
- endLLMRequestSpan({
1249
+ endLLMRequestSpan(llmSpan, {
1242
1250
  model: model || this.getModelConfig().model || "",
1243
1251
  success: false,
1244
1252
  error: error instanceof Error ? error.message : String(error),
@@ -1271,7 +1279,103 @@ export class AIManager {
1271
1279
  // Set loading to false first
1272
1280
  this.setIsLoading(false);
1273
1281
 
1274
- // Inject pending notifications from background tasks
1282
+ // Clear temporary rules
1283
+ this.permissionManager?.clearTemporaryRules();
1284
+
1285
+ // Clear abort controllers
1286
+ this.abortController = null;
1287
+ this.toolAbortController = null;
1288
+
1289
+ // Execute Stop/SubagentStop hooks only if the operation was not aborted
1290
+ const isCurrentlyAborted =
1291
+ abortController.signal.aborted || toolAbortController.signal.aborted;
1292
+
1293
+ if (!isCurrentlyAborted) {
1294
+ // Record committed snapshots to message history for the final turn
1295
+ if (this.reversionManager) {
1296
+ const snapshots =
1297
+ this.reversionManager.getAndClearCommittedSnapshots();
1298
+ if (snapshots.length > 0) {
1299
+ this.messageManager.addFileHistoryBlock(snapshots);
1300
+ }
1301
+ }
1302
+
1303
+ // Goal evaluation — supersedes Stop hooks when active
1304
+ const goalManager = this.container.has("GoalManager")
1305
+ ? this.container.get<import("./goalManager.js").GoalManager>(
1306
+ "GoalManager",
1307
+ )
1308
+ : undefined;
1309
+
1310
+ let goalContinuing = false;
1311
+
1312
+ if (goalManager?.isGoalActive() && !this.subagentType) {
1313
+ // 1. Increment turn count and check circuit breakers
1314
+ goalManager.incrementTurnCount();
1315
+ const circuitBreaker = goalManager.checkCircuitBreakers();
1316
+
1317
+ if (circuitBreaker) {
1318
+ goalManager.clearGoal();
1319
+ logger?.info(`[Goal] ${circuitBreaker}`);
1320
+ this.messageManager.addUserMessage({
1321
+ content: `<system-reminder>${circuitBreaker}</system-reminder>`,
1322
+ isMeta: true,
1323
+ });
1324
+ // Fall through to normal Stop hooks on the final turn
1325
+ } else {
1326
+ // 2. Evaluate goal
1327
+ const evaluation = await goalManager.evaluateGoal(
1328
+ abortController.signal,
1329
+ );
1330
+
1331
+ if (evaluation.isMet) {
1332
+ goalManager.clearGoal();
1333
+ logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
1334
+ this.messageManager.addUserMessage({
1335
+ content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
1336
+ isMeta: true,
1337
+ });
1338
+ // Fall through to normal Stop hooks on the final turn
1339
+ } else {
1340
+ const goal = goalManager.getGoal()!;
1341
+ goal.lastReason = evaluation.reason;
1342
+ logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
1343
+ this.messageManager.addUserMessage({
1344
+ content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
1345
+ isMeta: true,
1346
+ });
1347
+ // Keep loading state active to prevent UI flicker
1348
+ this.setIsLoading(true);
1349
+ goalContinuing = true;
1350
+ // Restart outer loop to continue goal pursuit
1351
+ shouldRestart = true;
1352
+ turnOffset = 0;
1353
+ }
1354
+ }
1355
+ }
1356
+
1357
+ // Skip Stop hooks when goal evaluator is continuing the conversation
1358
+ if (goalContinuing) {
1359
+ // Goal evaluator supersedes Stop hooks
1360
+ } else {
1361
+ const shouldContinue = await this.executeStopHooks();
1362
+
1363
+ // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1364
+ // restart the AI conversation cycle
1365
+ if (shouldContinue) {
1366
+ logger?.info(
1367
+ `${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
1368
+ );
1369
+
1370
+ // Restart the conversation to let AI fix the issues
1371
+ shouldRestart = true;
1372
+ turnOffset = 0;
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ // Inject pending notifications from background tasks (after Stop hooks,
1378
+ // aligned with Claude Code which fires Stop hooks unconditionally)
1275
1379
  const notificationQueue = this.container.has("NotificationQueue")
1276
1380
  ? this.container.get<NotificationQueue>("NotificationQueue")
1277
1381
  : undefined;
@@ -1292,102 +1396,6 @@ export class AIManager {
1292
1396
  // Restart outer loop to process the notifications
1293
1397
  shouldRestart = true;
1294
1398
  turnOffset = 0;
1295
- } else {
1296
- // Clear temporary rules
1297
- this.permissionManager?.clearTemporaryRules();
1298
-
1299
- // Clear abort controllers
1300
- this.abortController = null;
1301
- this.toolAbortController = null;
1302
-
1303
- // Execute Stop/SubagentStop hooks only if the operation was not aborted
1304
- const isCurrentlyAborted =
1305
- abortController.signal.aborted ||
1306
- toolAbortController.signal.aborted;
1307
-
1308
- if (!isCurrentlyAborted) {
1309
- // Record committed snapshots to message history for the final turn
1310
- if (this.reversionManager) {
1311
- const snapshots =
1312
- this.reversionManager.getAndClearCommittedSnapshots();
1313
- if (snapshots.length > 0) {
1314
- this.messageManager.addFileHistoryBlock(snapshots);
1315
- }
1316
- }
1317
-
1318
- // Goal evaluation — supersedes Stop hooks when active
1319
- const goalManager = this.container.has("GoalManager")
1320
- ? this.container.get<import("./goalManager.js").GoalManager>(
1321
- "GoalManager",
1322
- )
1323
- : undefined;
1324
-
1325
- let goalContinuing = false;
1326
-
1327
- if (goalManager?.isGoalActive() && !this.subagentType) {
1328
- // 1. Increment turn count and check circuit breakers
1329
- goalManager.incrementTurnCount();
1330
- const circuitBreaker = goalManager.checkCircuitBreakers();
1331
-
1332
- if (circuitBreaker) {
1333
- goalManager.clearGoal();
1334
- logger?.info(`[Goal] ${circuitBreaker}`);
1335
- this.messageManager.addUserMessage({
1336
- content: `<system-reminder>${circuitBreaker}</system-reminder>`,
1337
- isMeta: true,
1338
- });
1339
- // Fall through to normal Stop hooks on the final turn
1340
- } else {
1341
- // 2. Evaluate goal
1342
- const evaluation = await goalManager.evaluateGoal(
1343
- abortController.signal,
1344
- );
1345
-
1346
- if (evaluation.isMet) {
1347
- goalManager.clearGoal();
1348
- logger?.info(`[Goal] Goal achieved: ${evaluation.reason}`);
1349
- this.messageManager.addUserMessage({
1350
- content: `<system-reminder>Goal achieved: ${evaluation.reason}</system-reminder>`,
1351
- isMeta: true,
1352
- });
1353
- // Fall through to normal Stop hooks on the final turn
1354
- } else {
1355
- const goal = goalManager.getGoal()!;
1356
- goal.lastReason = evaluation.reason;
1357
- logger?.info(`[Goal] Not yet met: ${evaluation.reason}`);
1358
- this.messageManager.addUserMessage({
1359
- content: `<system-reminder>Goal not yet met: ${evaluation.reason}. Continue working toward: ${goal.condition}</system-reminder>`,
1360
- isMeta: true,
1361
- });
1362
- // Keep loading state active to prevent UI flicker
1363
- this.setIsLoading(true);
1364
- goalContinuing = true;
1365
- // Restart outer loop to continue goal pursuit
1366
- shouldRestart = true;
1367
- turnOffset = 0;
1368
- }
1369
- }
1370
- }
1371
-
1372
- // Skip Stop hooks when goal evaluator is continuing the conversation
1373
- if (goalContinuing) {
1374
- // Goal evaluator supersedes Stop hooks
1375
- } else {
1376
- const shouldContinue = await this.executeStopHooks();
1377
-
1378
- // If Stop/SubagentStop hooks indicate we should continue (due to blocking errors),
1379
- // restart the AI conversation cycle
1380
- if (shouldContinue) {
1381
- logger?.info(
1382
- `${this.subagentType ? "SubagentStop" : "Stop"} hooks indicate issues need fixing, continuing conversation...`,
1383
- );
1384
-
1385
- // Restart the conversation to let AI fix the issues
1386
- shouldRestart = true;
1387
- turnOffset = 0;
1388
- }
1389
- }
1390
- }
1391
1399
  }
1392
1400
  }
1393
1401
 
@@ -1653,6 +1661,7 @@ export class AIManager {
1653
1661
  shortResult: toolResult.shortResult,
1654
1662
  isManuallyBackgrounded: toolResult.isManuallyBackgrounded,
1655
1663
  startLineNumber: toolResult.startLineNumber,
1664
+ images: toolResult.images,
1656
1665
  timestamp: Date.now(),
1657
1666
  });
1658
1667
 
@@ -15,6 +15,7 @@ import {
15
15
  releaseSchedulerLock,
16
16
  registerSchedulerLockCleanup,
17
17
  } from "../utils/cronTasksLock.js";
18
+ import { ensureWaveRuntimeFilesExcluded } from "../utils/gitUtils.js";
18
19
 
19
20
  export class CronManager {
20
21
  private jobs = new Map<string, CronJob>();
@@ -100,6 +101,7 @@ export class CronManager {
100
101
  }
101
102
 
102
103
  private tryLock(workdir: string): void {
104
+ ensureWaveRuntimeFilesExcluded(workdir);
103
105
  tryAcquireSchedulerLock({ dir: workdir, sessionId: this.sessionId })
104
106
  .then((acquired) => {
105
107
  if (acquired) {
@@ -17,7 +17,6 @@ import type { HookManager } from "./hookManager.js";
17
17
  import type { PermissionManager } from "./permissionManager.js";
18
18
  import { isValidHookEvent } from "../types/hooks.js";
19
19
  import { ConfigurationService } from "../services/configurationService.js";
20
- import { ensureGlobalGitIgnore } from "../utils/fileUtils.js";
21
20
  import { Container } from "../utils/container.js";
22
21
 
23
22
  import type {
@@ -97,9 +96,6 @@ export class LiveConfigManager {
97
96
  if (projectPaths) {
98
97
  for (const projectPath of projectPaths) {
99
98
  if (existsSync(projectPath)) {
100
- if (projectPath.endsWith("settings.local.json")) {
101
- await ensureGlobalGitIgnore("**/.wave/settings.local.json");
102
- }
103
99
  await this.fileWatcher.watchFile(projectPath, (event) =>
104
100
  this.handleFileChange(event, "project"),
105
101
  );
@@ -364,14 +360,6 @@ export class LiveConfigManager {
364
360
 
365
361
  // Handle file creation or modification
366
362
  if (event.type === "change" || event.type === "create") {
367
- if (
368
- source === "project" &&
369
- event.path.endsWith("settings.local.json") &&
370
- event.type === "create"
371
- ) {
372
- await ensureGlobalGitIgnore("**/.wave/settings.local.json");
373
- }
374
-
375
363
  // Add small delay to ensure file write is complete
376
364
  await new Promise((resolve) => setTimeout(resolve, 50));
377
365
 
@@ -848,9 +848,16 @@ export class McpManager {
848
848
  textContent.push(String(result.content));
849
849
  }
850
850
 
851
+ const textContentStr =
852
+ textContent.length > 0
853
+ ? textContent.join("\n")
854
+ : images.length > 0
855
+ ? `Tool returned ${images.length} image(s).`
856
+ : "No content";
857
+
851
858
  return {
852
859
  success: true,
853
- content: textContent.length > 0 ? textContent.join("\n") : "No content",
860
+ content: textContentStr,
854
861
  images: images.length > 0 ? images : undefined,
855
862
  serverName,
856
863
  };
@@ -31,6 +31,7 @@ import type { MemoryRuleManager } from "./MemoryRuleManager.js";
31
31
  import type { MemoryRule } from "../types/memoryRule.js";
32
32
  import type { MemoryService } from "../services/memory.js";
33
33
  import { pathEncoder } from "../utils/pathEncoder.js";
34
+ import { estimateTokens } from "../utils/tokenEstimate.js";
34
35
  import { READ_TOOL_NAME } from "../constants/tools.js";
35
36
 
36
37
  import { Container } from "../utils/container.js";
@@ -1063,7 +1064,7 @@ export class MessageManager {
1063
1064
  /**
1064
1065
  * Get recent file read contents, sorted by timestamp (newest first).
1065
1066
  * @param maxFiles - Maximum number of files to return
1066
- * @param maxTokensPerFile - Maximum tokens per file (~4 chars/token)
1067
+ * @param maxTokensPerFile - Maximum tokens per file (CJK-aware estimation)
1067
1068
  * @returns Array of { path, content } sorted by recency
1068
1069
  */
1069
1070
  public getRecentFileReads(
@@ -1076,10 +1077,14 @@ export class MessageManager {
1076
1077
 
1077
1078
  const result: Array<{ path: string; content: string }> = [];
1078
1079
  for (const [path, { content }] of sorted) {
1079
- const truncated =
1080
- content.length > maxTokensPerFile * 4
1081
- ? content.slice(0, maxTokensPerFile * 4)
1082
- : content;
1080
+ const tokenCount = estimateTokens(content);
1081
+ let truncated = content;
1082
+ if (tokenCount > maxTokensPerFile) {
1083
+ // Truncate proportionally to fit within the token budget
1084
+ const ratio = maxTokensPerFile / tokenCount;
1085
+ const cutIndex = Math.floor(content.length * ratio);
1086
+ truncated = content.slice(0, cutIndex);
1087
+ }
1083
1088
  result.push({ path, content: truncated });
1084
1089
  }
1085
1090
  return result;
@@ -279,6 +279,7 @@ class ToolManager {
279
279
  endToolSpan({
280
280
  success: result.success,
281
281
  durationMs: Date.now() - toolStartTime,
282
+ output: result.content,
282
283
  });
283
284
  return result;
284
285
  } catch (error) {
@@ -303,6 +304,7 @@ class ToolManager {
303
304
  endToolSpan({
304
305
  success: result.success,
305
306
  durationMs: Date.now() - toolStartTime,
307
+ output: result.content,
306
308
  });
307
309
  return result;
308
310
  } catch (error) {
@@ -47,6 +47,7 @@ import {
47
47
  mergeRemoteSettings,
48
48
  } from "./remoteSettingsService.js";
49
49
  import { createAuthAwareFetch } from "./authService.js";
50
+ import { ensureWaveRuntimeFilesExcluded } from "../utils/gitUtils.js";
50
51
 
51
52
  /**
52
53
  * Default ConfigurationService implementation
@@ -791,6 +792,7 @@ export class ConfigurationService {
791
792
 
792
793
  // Ensure .wave directory exists
793
794
  const waveDir = path.join(workdir, ".wave");
795
+ ensureWaveRuntimeFilesExcluded(workdir);
794
796
  if (!existsSync(waveDir)) {
795
797
  await fs.mkdir(waveDir, { recursive: true });
796
798
  }
@@ -2,7 +2,12 @@
2
2
  * Session Tracing -- OpenTelemetry Span Management
3
3
  *
4
4
  * Provides span creation/ending APIs for interactions, LLM requests, and tool
5
- * executions with AsyncLocalStorage context propagation and stale span cleanup.
5
+ * executions. Uses two independent AsyncLocalStorage contexts:
6
+ * - interactionContext: holds the interaction span for the entire turn
7
+ * - toolContext: holds the current tool span (cleared on end)
8
+ *
9
+ * LLM request spans do not enter any ALS; they are passed explicitly to
10
+ * endLLMRequestSpan, aligning with Claude Code's approach.
6
11
  */
7
12
 
8
13
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -16,12 +21,8 @@ import type { LLMRequestMetadata, ToolMetadata } from "../types/telemetry.js";
16
21
 
17
22
  // -- AsyncLocalStorage for context propagation --
18
23
 
19
- const spanContext = new AsyncLocalStorage<Span>();
20
-
21
- // -- LIFO stacks for nested span tracking --
22
-
23
- const llmSpans: Span[] = [];
24
- const toolSpans: Span[] = [];
24
+ const interactionContext = new AsyncLocalStorage<Span | undefined>();
25
+ const toolContext = new AsyncLocalStorage<Span | undefined>();
25
26
 
26
27
  // -- Tracer accessor --
27
28
 
@@ -32,37 +33,19 @@ function getTracer() {
32
33
  return otelApi.trace.getTracer("wave");
33
34
  }
34
35
 
35
- // -- Helper: create child span with parent context --
36
-
37
- function startChildSpan(
38
- name: string,
39
- attributes: Record<string, string | number>,
40
- ): Span | undefined {
41
- const tracer = getTracer();
42
- if (!tracer) return undefined;
43
-
44
- const parent = spanContext.getStore();
45
- let span: Span;
46
- if (parent) {
47
- const otelApi = getOTELApi()!;
48
- const ctx = otelApi.trace.setSpan(otelApi.context.active(), parent);
49
- span = tracer.startSpan(name, { attributes }, ctx);
50
- } else {
51
- span = tracer.startSpan(name, { attributes });
52
- }
53
- spanContext.enterWith(span);
54
- return span;
55
- }
56
-
57
36
  // -- Public API --
58
37
 
59
38
  /**
60
39
  * Creates an interaction span for a user turn.
40
+ * The span is stored in interactionContext for the duration of the turn.
61
41
  */
62
42
  export function startInteractionSpan(
63
43
  userPrompt: string,
64
44
  sequence: number,
65
45
  ): Span | undefined {
46
+ const tracer = getTracer();
47
+ if (!tracer) return undefined;
48
+
66
49
  const config = getCurrentConfig();
67
50
  const attributes: Record<string, string | number> = {
68
51
  "span.type": "interaction",
@@ -72,25 +55,33 @@ export function startInteractionSpan(
72
55
  if (config?.logUserPrompts) {
73
56
  attributes.user_prompt = userPrompt;
74
57
  }
75
- return startChildSpan("interaction", attributes);
58
+
59
+ const span = tracer.startSpan("interaction", { attributes });
60
+ interactionContext.enterWith(span);
61
+ return span;
76
62
  }
77
63
 
78
64
  /**
79
- * Ends the current active interaction span.
65
+ * Ends the current interaction span and clears the context.
80
66
  */
81
67
  export function endInteractionSpan(): void {
82
- const span = spanContext.getStore();
68
+ const span = interactionContext.getStore();
83
69
  if (!span) return;
84
70
  span.end();
71
+ interactionContext.enterWith(undefined);
85
72
  }
86
73
 
87
74
  /**
88
- * Creates an LLM request span as a child of the current active span.
75
+ * Creates an LLM request span as a child of the interaction span.
76
+ * Does NOT enter any ALS — the span must be passed explicitly to endLLMRequestSpan.
89
77
  */
90
78
  export function startLLMRequestSpan(
91
79
  model: string,
92
80
  options?: { context?: string },
93
81
  ): Span | undefined {
82
+ const tracer = getTracer();
83
+ if (!tracer) return undefined;
84
+
94
85
  const attributes: Record<string, string> = {
95
86
  "span.type": "llm_request",
96
87
  model,
@@ -99,18 +90,26 @@ export function startLLMRequestSpan(
99
90
  attributes["llm_request.context"] = options.context;
100
91
  }
101
92
 
102
- const span = startChildSpan("llm.request", attributes);
103
- if (span) {
104
- llmSpans.push(span);
93
+ const parent = interactionContext.getStore();
94
+ let span: Span;
95
+ if (parent) {
96
+ const otelApi = getOTELApi()!;
97
+ const ctx = otelApi.trace.setSpan(otelApi.context.active(), parent);
98
+ span = tracer.startSpan("llm.request", { attributes }, ctx);
99
+ } else {
100
+ span = tracer.startSpan("llm.request", { attributes });
105
101
  }
106
102
  return span;
107
103
  }
108
104
 
109
105
  /**
110
- * Ends the most recent LLM request span with response metadata.
106
+ * Ends an LLM request span with response metadata.
107
+ * The span is passed explicitly — no ALS is read or modified.
111
108
  */
112
- export function endLLMRequestSpan(metadata: LLMRequestMetadata): void {
113
- const span = llmSpans.pop();
109
+ export function endLLMRequestSpan(
110
+ span: Span | undefined,
111
+ metadata: LLMRequestMetadata,
112
+ ): void {
114
113
  if (!span) return;
115
114
 
116
115
  if (metadata.inputTokens != null)
@@ -129,19 +128,19 @@ export function endLLMRequestSpan(metadata: LLMRequestMetadata): void {
129
128
  span.setAttribute("has_tool_call", metadata.hasToolCall);
130
129
 
131
130
  span.end();
132
-
133
- if (llmSpans.length > 0) {
134
- spanContext.enterWith(llmSpans[llmSpans.length - 1]);
135
- }
136
131
  }
137
132
 
138
133
  /**
139
- * Creates a tool execution span as a child of the current active span.
134
+ * Creates a tool execution span as a child of the interaction span.
135
+ * Enters toolContext with the new span.
140
136
  */
141
137
  export function startToolSpan(
142
138
  toolName: string,
143
139
  input?: unknown,
144
140
  ): Span | undefined {
141
+ const tracer = getTracer();
142
+ if (!tracer) return undefined;
143
+
145
144
  const config = getCurrentConfig();
146
145
  const attributes: Record<string, string | number> = {
147
146
  "span.type": "tool",
@@ -155,42 +154,40 @@ export function startToolSpan(
155
154
  attributes.tool_input = inputStr;
156
155
  }
157
156
 
158
- const span = startChildSpan(`tool.${toolName}`, attributes);
159
- if (span) {
160
- toolSpans.push(span);
157
+ const parent = interactionContext.getStore();
158
+ let span: Span;
159
+ if (parent) {
160
+ const otelApi = getOTELApi()!;
161
+ const ctx = otelApi.trace.setSpan(otelApi.context.active(), parent);
162
+ span = tracer.startSpan(`tool.${toolName}`, { attributes }, ctx);
163
+ } else {
164
+ span = tracer.startSpan(`tool.${toolName}`, { attributes });
161
165
  }
166
+ toolContext.enterWith(span);
162
167
  return span;
163
168
  }
164
169
 
165
170
  /**
166
- * Ends a tool span with execution metadata.
171
+ * Ends the current tool span with execution metadata.
172
+ * Reads the span from toolContext, then clears it.
167
173
  */
168
174
  export function endToolSpan(metadata: ToolMetadata): void {
169
- const span = toolSpans.pop();
175
+ const span = toolContext.getStore();
170
176
  if (!span) return;
171
177
 
172
178
  span.setAttribute("success", metadata.success);
173
179
  if (metadata.error) span.setAttribute("error", metadata.error);
174
180
  span.setAttribute("duration_ms", metadata.durationMs);
175
181
 
176
- span.end();
177
-
178
- if (toolSpans.length > 0) {
179
- spanContext.enterWith(toolSpans[toolSpans.length - 1]);
182
+ const config = getCurrentConfig();
183
+ if (config?.logToolContent && metadata.output) {
184
+ let outputStr = metadata.output;
185
+ if (outputStr.length > 1000) {
186
+ outputStr = outputStr.substring(0, 1000);
187
+ }
188
+ span.setAttribute("tool_output", outputStr);
180
189
  }
181
- }
182
190
 
183
- /**
184
- * Returns the current active span from ALS context.
185
- */
186
- export function getActiveInteractionSpan(): Span | undefined {
187
- return spanContext.getStore();
188
- }
189
-
190
- /**
191
- * Executes `fn` with `span` as the active context via ALS.
192
- * Useful for parallel tool calls that each need their own span context.
193
- */
194
- export function withSpanContext<T>(span: Span, fn: () => T): T {
195
- return spanContext.run(span, fn);
191
+ span.end();
192
+ toolContext.enterWith(undefined);
196
193
  }