agentfootprint 9.114.2 → 9.115.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 (230) hide show
  1. package/AGENTS.md +1 -1
  2. package/CAPABILITIES.md +3 -0
  3. package/ai-instructions/claude-code/SKILL.md +1 -1
  4. package/dist/adapters/hosting/agentCoreA2A.js +5 -0
  5. package/dist/adapters/hosting/agentCoreA2A.js.map +1 -1
  6. package/dist/adapters/hosting/agentcore.js +5 -0
  7. package/dist/adapters/hosting/agentcore.js.map +1 -1
  8. package/dist/adapters/hosting/foundryResponses.js +5 -0
  9. package/dist/adapters/hosting/foundryResponses.js.map +1 -1
  10. package/dist/adapters/observability/agentcore.js +18 -5
  11. package/dist/adapters/observability/agentcore.js.map +1 -1
  12. package/dist/adapters/observability/audit.js +17 -4
  13. package/dist/adapters/observability/audit.js.map +1 -1
  14. package/dist/adapters/observability/otel.js +890 -33
  15. package/dist/adapters/observability/otel.js.map +1 -1
  16. package/dist/artifacts/capability.js +9 -3
  17. package/dist/artifacts/capability.js.map +1 -1
  18. package/dist/bridge/eventMeta.js +40 -1
  19. package/dist/bridge/eventMeta.js.map +1 -1
  20. package/dist/core/Agent.js +40 -6
  21. package/dist/core/Agent.js.map +1 -1
  22. package/dist/core/RunnerBase.js +32 -1
  23. package/dist/core/RunnerBase.js.map +1 -1
  24. package/dist/core/agent/AgentBuilder.js +13 -1
  25. package/dist/core/agent/AgentBuilder.js.map +1 -1
  26. package/dist/core/agent/stages/toolCalls.js +64 -7
  27. package/dist/core/agent/stages/toolCalls.js.map +1 -1
  28. package/dist/core/flowchartAsTool.js +8 -2
  29. package/dist/core/flowchartAsTool.js.map +1 -1
  30. package/dist/core/runbook/runbookAsTool.js +7 -2
  31. package/dist/core/runbook/runbookAsTool.js.map +1 -1
  32. package/dist/core/toolShownArgs.js +55 -0
  33. package/dist/core/toolShownArgs.js.map +1 -0
  34. package/dist/esm/adapters/hosting/agentCoreA2A.d.ts +14 -1
  35. package/dist/esm/adapters/hosting/agentCoreA2A.js +5 -0
  36. package/dist/esm/adapters/hosting/agentCoreA2A.js.map +1 -1
  37. package/dist/esm/adapters/hosting/agentcore.d.ts +18 -2
  38. package/dist/esm/adapters/hosting/agentcore.js +5 -0
  39. package/dist/esm/adapters/hosting/agentcore.js.map +1 -1
  40. package/dist/esm/adapters/hosting/foundryResponses.d.ts +15 -1
  41. package/dist/esm/adapters/hosting/foundryResponses.js +5 -0
  42. package/dist/esm/adapters/hosting/foundryResponses.js.map +1 -1
  43. package/dist/esm/adapters/observability/agentcore.d.ts +17 -5
  44. package/dist/esm/adapters/observability/agentcore.js +18 -5
  45. package/dist/esm/adapters/observability/agentcore.js.map +1 -1
  46. package/dist/esm/adapters/observability/audit.d.ts +6 -2
  47. package/dist/esm/adapters/observability/audit.js +17 -4
  48. package/dist/esm/adapters/observability/audit.js.map +1 -1
  49. package/dist/esm/adapters/observability/otel.d.ts +240 -11
  50. package/dist/esm/adapters/observability/otel.js +890 -33
  51. package/dist/esm/adapters/observability/otel.js.map +1 -1
  52. package/dist/esm/artifacts/capability.d.ts +3 -1
  53. package/dist/esm/artifacts/capability.js +9 -3
  54. package/dist/esm/artifacts/capability.js.map +1 -1
  55. package/dist/esm/bridge/eventMeta.d.ts +32 -0
  56. package/dist/esm/bridge/eventMeta.js +38 -0
  57. package/dist/esm/bridge/eventMeta.js.map +1 -1
  58. package/dist/esm/core/Agent.d.ts +18 -5
  59. package/dist/esm/core/Agent.js +42 -8
  60. package/dist/esm/core/Agent.js.map +1 -1
  61. package/dist/esm/core/RunnerBase.d.ts +20 -0
  62. package/dist/esm/core/RunnerBase.js +32 -1
  63. package/dist/esm/core/RunnerBase.js.map +1 -1
  64. package/dist/esm/core/agent/AgentBuilder.js +13 -1
  65. package/dist/esm/core/agent/AgentBuilder.js.map +1 -1
  66. package/dist/esm/core/agent/stages/toolCalls.d.ts +11 -0
  67. package/dist/esm/core/agent/stages/toolCalls.js +64 -7
  68. package/dist/esm/core/agent/stages/toolCalls.js.map +1 -1
  69. package/dist/esm/core/flowchartAsTool.js +8 -2
  70. package/dist/esm/core/flowchartAsTool.js.map +1 -1
  71. package/dist/esm/core/runbook/runbookAsTool.js +7 -2
  72. package/dist/esm/core/runbook/runbookAsTool.js.map +1 -1
  73. package/dist/esm/core/toolShownArgs.d.ts +62 -0
  74. package/dist/esm/core/toolShownArgs.js +49 -0
  75. package/dist/esm/core/toolShownArgs.js.map +1 -0
  76. package/dist/esm/events/payloads.d.ts +122 -6
  77. package/dist/esm/events/registry.d.ts +3 -1
  78. package/dist/esm/events/registry.js +2 -0
  79. package/dist/esm/events/registry.js.map +1 -1
  80. package/dist/esm/events/types.d.ts +7 -0
  81. package/dist/esm/hosting/artifactWire.d.ts +10 -2
  82. package/dist/esm/hosting/artifactWire.js +20 -2
  83. package/dist/esm/hosting/artifactWire.js.map +1 -1
  84. package/dist/esm/hosting/doorGuard.d.ts +280 -0
  85. package/dist/esm/hosting/doorGuard.js +499 -0
  86. package/dist/esm/hosting/doorGuard.js.map +1 -0
  87. package/dist/esm/hosting/errors.d.ts +113 -0
  88. package/dist/esm/hosting/errors.js +177 -0
  89. package/dist/esm/hosting/errors.js.map +1 -1
  90. package/dist/esm/hosting/httpHost.d.ts +32 -3
  91. package/dist/esm/hosting/httpHost.js +218 -4
  92. package/dist/esm/hosting/httpHost.js.map +1 -1
  93. package/dist/esm/hosting/index.d.ts +7 -5
  94. package/dist/esm/hosting/index.js +10 -3
  95. package/dist/esm/hosting/index.js.map +1 -1
  96. package/dist/esm/hosting/ingressRecord.d.ts +64 -10
  97. package/dist/esm/hosting/ingressRecord.js +65 -6
  98. package/dist/esm/hosting/ingressRecord.js.map +1 -1
  99. package/dist/esm/hosting/nodeHost.d.ts +32 -2
  100. package/dist/esm/hosting/nodeHost.js +10 -0
  101. package/dist/esm/hosting/nodeHost.js.map +1 -1
  102. package/dist/esm/hosting/standingAgent.js +362 -51
  103. package/dist/esm/hosting/standingAgent.js.map +1 -1
  104. package/dist/esm/hosting/turnArtifacts.d.ts +93 -0
  105. package/dist/esm/hosting/turnArtifacts.js +126 -0
  106. package/dist/esm/hosting/turnArtifacts.js.map +1 -0
  107. package/dist/esm/hosting/types.d.ts +196 -3
  108. package/dist/esm/hosting/types.js +10 -0
  109. package/dist/esm/hosting/types.js.map +1 -1
  110. package/dist/esm/hosting/webSocketConversation.d.ts +15 -1
  111. package/dist/esm/hosting/webSocketConversation.js +54 -2
  112. package/dist/esm/hosting/webSocketConversation.js.map +1 -1
  113. package/dist/esm/integrity/disposition/ledger.d.ts +8 -0
  114. package/dist/esm/integrity/disposition/ledger.js +0 -0
  115. package/dist/esm/integrity/disposition/ledger.js.map +1 -1
  116. package/dist/esm/lib/mcp/mcpServe.js +39 -2
  117. package/dist/esm/lib/mcp/mcpServe.js.map +1 -1
  118. package/dist/esm/lib/mcp/types.d.ts +30 -0
  119. package/dist/esm/recorders/observability/commentary/artifactPhrases.d.ts +4 -0
  120. package/dist/esm/recorders/observability/commentary/artifactPhrases.js +9 -0
  121. package/dist/esm/recorders/observability/commentary/artifactPhrases.js.map +1 -1
  122. package/dist/esm/recorders/observability/commentary/commentaryTemplates.js +30 -3
  123. package/dist/esm/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
  124. package/dist/esm/recorders/observability/recordRun.d.ts +12 -0
  125. package/dist/esm/recorders/observability/recordRun.js +17 -1
  126. package/dist/esm/recorders/observability/recordRun.js.map +1 -1
  127. package/dist/esm/strategies/attach.js +87 -1
  128. package/dist/esm/strategies/attach.js.map +1 -1
  129. package/dist/events/registry.js +2 -0
  130. package/dist/events/registry.js.map +1 -1
  131. package/dist/hosting/artifactWire.js +21 -3
  132. package/dist/hosting/artifactWire.js.map +1 -1
  133. package/dist/hosting/doorGuard.js +509 -0
  134. package/dist/hosting/doorGuard.js.map +1 -0
  135. package/dist/hosting/errors.js +183 -1
  136. package/dist/hosting/errors.js.map +1 -1
  137. package/dist/hosting/httpHost.js +218 -4
  138. package/dist/hosting/httpHost.js.map +1 -1
  139. package/dist/hosting/index.js +19 -2
  140. package/dist/hosting/index.js.map +1 -1
  141. package/dist/hosting/ingressRecord.js +66 -6
  142. package/dist/hosting/ingressRecord.js.map +1 -1
  143. package/dist/hosting/nodeHost.js +11 -1
  144. package/dist/hosting/nodeHost.js.map +1 -1
  145. package/dist/hosting/standingAgent.js +359 -48
  146. package/dist/hosting/standingAgent.js.map +1 -1
  147. package/dist/hosting/turnArtifacts.js +130 -0
  148. package/dist/hosting/turnArtifacts.js.map +1 -0
  149. package/dist/hosting/types.js +11 -1
  150. package/dist/hosting/types.js.map +1 -1
  151. package/dist/hosting/webSocketConversation.js +54 -2
  152. package/dist/hosting/webSocketConversation.js.map +1 -1
  153. package/dist/integrity/disposition/ledger.js +0 -0
  154. package/dist/integrity/disposition/ledger.js.map +1 -1
  155. package/dist/lib/mcp/mcpServe.js +39 -2
  156. package/dist/lib/mcp/mcpServe.js.map +1 -1
  157. package/dist/recorders/observability/commentary/artifactPhrases.js +10 -1
  158. package/dist/recorders/observability/commentary/artifactPhrases.js.map +1 -1
  159. package/dist/recorders/observability/commentary/commentaryTemplates.js +29 -2
  160. package/dist/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
  161. package/dist/recorders/observability/recordRun.js +20 -3
  162. package/dist/recorders/observability/recordRun.js.map +1 -1
  163. package/dist/strategies/attach.js +87 -1
  164. package/dist/strategies/attach.js.map +1 -1
  165. package/dist/types/adapters/hosting/agentCoreA2A.d.ts +14 -1
  166. package/dist/types/adapters/hosting/agentCoreA2A.d.ts.map +1 -1
  167. package/dist/types/adapters/hosting/agentcore.d.ts +18 -2
  168. package/dist/types/adapters/hosting/agentcore.d.ts.map +1 -1
  169. package/dist/types/adapters/hosting/foundryResponses.d.ts +15 -1
  170. package/dist/types/adapters/hosting/foundryResponses.d.ts.map +1 -1
  171. package/dist/types/adapters/observability/agentcore.d.ts +17 -5
  172. package/dist/types/adapters/observability/agentcore.d.ts.map +1 -1
  173. package/dist/types/adapters/observability/audit.d.ts +6 -2
  174. package/dist/types/adapters/observability/audit.d.ts.map +1 -1
  175. package/dist/types/adapters/observability/otel.d.ts +240 -11
  176. package/dist/types/adapters/observability/otel.d.ts.map +1 -1
  177. package/dist/types/artifacts/capability.d.ts +3 -1
  178. package/dist/types/artifacts/capability.d.ts.map +1 -1
  179. package/dist/types/bridge/eventMeta.d.ts +32 -0
  180. package/dist/types/bridge/eventMeta.d.ts.map +1 -1
  181. package/dist/types/core/Agent.d.ts +18 -5
  182. package/dist/types/core/Agent.d.ts.map +1 -1
  183. package/dist/types/core/RunnerBase.d.ts +20 -0
  184. package/dist/types/core/RunnerBase.d.ts.map +1 -1
  185. package/dist/types/core/agent/AgentBuilder.d.ts.map +1 -1
  186. package/dist/types/core/agent/stages/toolCalls.d.ts +11 -0
  187. package/dist/types/core/agent/stages/toolCalls.d.ts.map +1 -1
  188. package/dist/types/core/flowchartAsTool.d.ts.map +1 -1
  189. package/dist/types/core/runbook/runbookAsTool.d.ts.map +1 -1
  190. package/dist/types/core/toolShownArgs.d.ts +63 -0
  191. package/dist/types/core/toolShownArgs.d.ts.map +1 -0
  192. package/dist/types/events/payloads.d.ts +122 -6
  193. package/dist/types/events/payloads.d.ts.map +1 -1
  194. package/dist/types/events/registry.d.ts +3 -1
  195. package/dist/types/events/registry.d.ts.map +1 -1
  196. package/dist/types/events/types.d.ts +7 -0
  197. package/dist/types/events/types.d.ts.map +1 -1
  198. package/dist/types/hosting/artifactWire.d.ts +10 -2
  199. package/dist/types/hosting/artifactWire.d.ts.map +1 -1
  200. package/dist/types/hosting/doorGuard.d.ts +281 -0
  201. package/dist/types/hosting/doorGuard.d.ts.map +1 -0
  202. package/dist/types/hosting/errors.d.ts +113 -0
  203. package/dist/types/hosting/errors.d.ts.map +1 -1
  204. package/dist/types/hosting/httpHost.d.ts +32 -3
  205. package/dist/types/hosting/httpHost.d.ts.map +1 -1
  206. package/dist/types/hosting/index.d.ts +7 -5
  207. package/dist/types/hosting/index.d.ts.map +1 -1
  208. package/dist/types/hosting/ingressRecord.d.ts +64 -10
  209. package/dist/types/hosting/ingressRecord.d.ts.map +1 -1
  210. package/dist/types/hosting/nodeHost.d.ts +32 -2
  211. package/dist/types/hosting/nodeHost.d.ts.map +1 -1
  212. package/dist/types/hosting/standingAgent.d.ts.map +1 -1
  213. package/dist/types/hosting/turnArtifacts.d.ts +94 -0
  214. package/dist/types/hosting/turnArtifacts.d.ts.map +1 -0
  215. package/dist/types/hosting/types.d.ts +196 -3
  216. package/dist/types/hosting/types.d.ts.map +1 -1
  217. package/dist/types/hosting/webSocketConversation.d.ts +15 -1
  218. package/dist/types/hosting/webSocketConversation.d.ts.map +1 -1
  219. package/dist/types/integrity/disposition/ledger.d.ts +8 -0
  220. package/dist/types/integrity/disposition/ledger.d.ts.map +1 -1
  221. package/dist/types/lib/mcp/mcpServe.d.ts.map +1 -1
  222. package/dist/types/lib/mcp/types.d.ts +30 -0
  223. package/dist/types/lib/mcp/types.d.ts.map +1 -1
  224. package/dist/types/recorders/observability/commentary/artifactPhrases.d.ts +4 -0
  225. package/dist/types/recorders/observability/commentary/artifactPhrases.d.ts.map +1 -1
  226. package/dist/types/recorders/observability/commentary/commentaryTemplates.d.ts.map +1 -1
  227. package/dist/types/recorders/observability/recordRun.d.ts +12 -0
  228. package/dist/types/recorders/observability/recordRun.d.ts.map +1 -1
  229. package/dist/types/strategies/attach.d.ts.map +1 -1
  230. package/package.json +1 -1
@@ -63,10 +63,66 @@
63
63
  * context.evaluated ↦ N span events `agentfootprint.skill.routing`
64
64
  * — SYNTHESIZED name (one per routing entry),
65
65
  * not a registry-verbatim forward; apart from
66
- * it and `agentfootprint.tool.not_dispatched`
67
- * above, span events use the registry name
66
+ * it, `agentfootprint.tool.not_dispatched`
67
+ * above and `agentfootprint.integrity.check`
68
+ * below, span events use the registry name
68
69
  * verbatim
69
70
  *
71
+ * ## What an evaluation tool reads
72
+ *
73
+ * meta.sessionId ↦ `gen_ai.conversation.id` on the agent, chat
74
+ * and execute_tool spans of a session-bound run
75
+ * — the SHA-256 DIGEST of the session id by
76
+ * default (`conversationId: 'raw'` for the id
77
+ * itself, safe only behind a verifying door);
78
+ * never the runId in its place
79
+ * eval.score ↦ `gen_ai.evaluation.result` — the spec's
80
+ * evaluation event, carried as a SPAN event
81
+ * (the spec's reference emitter logs it; this
82
+ * adapter speaks the Tracer API only). On the
83
+ * span it evaluates while the turn is open; on
84
+ * a short `agentfootprint.evaluation` span
85
+ * under the turn once it closed — and PARENTED
86
+ * only when the score's own meta places it in
87
+ * that run's session. Otherwise (every
88
+ * `agent.emit` after the run) it rides an
89
+ * `agentfootprint.evaluation` span of its own,
90
+ * unparented, naming the ref. `explanation`
91
+ * only under `captureContent`; `evidence` never
92
+ * tools.absent / ↦ span event on the call's own span: a COUNT
93
+ * tools.coverage_declared per list (checked / not_checked /
94
+ * cannot_cover), the suggested tool's name only
95
+ * when this strategy has seen that tool run —
96
+ * never a sentence the tool author wrote
97
+ * agent.evidence_checked ↦ span event: verdict + unsupported COUNT; the
98
+ * values only under `captureContent`
99
+ * findings.standing ↦ span event per row (ids, enums, counts; an
100
+ * unknown id as a flag only), at most
101
+ * {@link MAX_STANDING_EVENTS_PER_TURN} per turn
102
+ * then one `agentfootprint.findings.standing_overflow`
103
+ * (SYNTHESIZED); a count per standing on the
104
+ * agent span when the turn closes
105
+ * integrity.disposition ↦ filed AFTER turn_end. By default exported
106
+ * only when a row is actionable (findings, or
107
+ * wiring rot): a short
108
+ * `agentfootprint.integrity.disposition` span
109
+ * under the turn, one
110
+ * `agentfootprint.integrity.check` span event
111
+ * (SYNTHESIZED) per actionable row.
112
+ * `checkAccounting: 'all'` for every row
113
+ * (captureToolContent only) ↦ `gen_ai.tool.call.arguments` /
114
+ * `gen_ai.tool.call.result` on the tool span of
115
+ * a call that ran and returned — the proposal
116
+ * snapshot with every rule-changed key
117
+ * withheld, and what the model READ; objects
118
+ * only; omitted over `maxContentChars`; never a
119
+ * code runner's program
120
+ *
121
+ * The structure events above ride the `explainability` switch; the
122
+ * evaluation event does not (it is a `gen_ai.*` signal, like the attributes).
123
+ * A span started after its parent ended (the disposition, a late score) is
124
+ * valid OTel; most trace views draw it outside the parent's bar.
125
+ *
70
126
  * ## Decisions = SPAN EVENTS, not attributes (design decision)
71
127
  *
72
128
  * Explainability signals (route decisions, skill routing, validation
@@ -89,16 +145,57 @@
89
145
  *
90
146
  * ## PII discipline
91
147
  *
92
- * Mirrors the #9 validation contract: attribute values NEVER echo
148
+ * Mirrors the #9 validation contract: by default attribute values NEVER echo
93
149
  * runtime VALUES that can carry PII —
94
150
  * - tool args → top-level key NAMES only (`agentfootprint.tool.args.keys`)
151
+ * (by default; see `captureToolContent`)
95
152
  * - tool results → `typeof` only (`agentfootprint.tool.result.type`)
153
+ * (by default; see `captureToolContent`)
96
154
  * - validation issues → path / expected / got TYPES (bounded upstream)
97
155
  * - decide() evidence → rule labels, operators, thresholds (developer
98
156
  * constants) and the engine's redaction-aware value SUMMARIES
99
- * - userPrompt / llm content / thinking → never emitted
157
+ * - userPrompt / llm content / thinking → never emitted (by default; see
158
+ * `captureContent`)
100
159
  * - error.fatal → stage + scope only (error MESSAGES can echo values)
101
160
  * - credential events carry no secrets by construction (registry contract)
161
+ * - the session id → its SHA-256 digest (`conversationId`)
162
+ * - absence / coverage events → list COUNTS, and the suggested tool's NAME
163
+ * only when this strategy has seen that tool run; never
164
+ * `looked_for`, a `what` / `why`, `tryInstead` or an unregistered
165
+ * `tryInsteadTool.tool` — any field a tool author writes can interpolate
166
+ * an argument (coverage README, "What that does not close"). The EVENT
167
+ * never carries them, content on or off; under `captureToolContent` an
168
+ * `absent()` call's result (the envelope itself) does, as its
169
+ * `gen_ai.tool.call.result`, beside the arguments any interpolation came
170
+ * from
171
+ * - evidence verdict → the unsupported COUNT; findings → ids, enums,
172
+ * counts, never the model's lines (an id the model wrote that names no
173
+ * result is a flag, not the id); disposition → check names + counts
174
+ * - eval.score → metric name, score, label (a vocabulary word by contract),
175
+ * target words from the payload's closed vocabularies; `evidence` never
176
+ * - EVERY string attribute outside the content switches is capped at 256
177
+ * characters and every list at 20 items plus an overflow marker, in one
178
+ * funnel (`capAttrs`) —
179
+ * whoever chose the value, a caller-sized session id or a model-written
180
+ * id cannot choose the size of an attribute. A capped value is cut with
181
+ * `…` (never mid surrogate pair): a cut id is no longer the id. Span
182
+ * NAMES carry a tool or model name at most 64 characters
183
+ * (`spanNamePart`), since a tool name is the model's text when it names
184
+ * no registered tool
185
+ * - the session digest hashes the id's UTF-8 bytes, where every lone
186
+ * surrogate becomes U+FFFD: two ids differing only in a lone surrogate
187
+ * share a digest. Library-minted ids are UUIDs; documented, not handled
188
+ *
189
+ * Two content switches, each off by default: `captureContent` (the turn's
190
+ * prompt and answer, the evidence gate's unsupported values, an evaluation's
191
+ * explanation) and `captureToolContent` (tool arguments and results). The
192
+ * adapter redacts nothing of its own: it exports what the events carry. A
193
+ * call's arguments are the proposal `tool_start` carried, SERIALIZED THEN
194
+ * (the engine hands the same object to the tool, which may write into it),
195
+ * with every key `tool_end` names in `changedArgKeys` — a rule rewrote it,
196
+ * or the tool's own `redact` policy hides it — withheld as `'REDACTED'`; the
197
+ * result is what the model read (`modelResult`, after the `onToolResult`
198
+ * scrub). Never a raw value a rule replaced, never a value a rule added.
102
199
  *
103
200
  * @example Basic — Honeycomb via OTLP
104
201
  * ```ts
@@ -132,7 +229,9 @@
132
229
  * });
133
230
  * ```
134
231
  */
232
+ import { filedNothing } from '../../integrity/disposition/ledger.js';
135
233
  import { lazyRequire } from '../../lib/lazyRequire.js';
234
+ import { sha256Hex } from '../../lib/time-travel/sha256.js';
136
235
  import { rateLimitedConsoleSink } from './deliveryErrors.js';
137
236
  // ─── Bounding helpers (PII / cardinality discipline) ─────────────────
138
237
  /** Hard caps for attribute payloads. Evidence is bounded upstream
@@ -141,7 +240,27 @@ const MAX_ATTR_CHARS = 256;
141
240
  const MAX_LIST_ITEMS = 20;
142
241
  function bound(value) {
143
242
  const s = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
144
- return s.length > MAX_ATTR_CHARS ? `${s.slice(0, MAX_ATTR_CHARS - 1)}…` : s;
243
+ return s.length > MAX_ATTR_CHARS ? `${cutAt(s, MAX_ATTR_CHARS - 1)}…` : s;
244
+ }
245
+ /**
246
+ * The first `n` UTF-16 units of `s`, one fewer when the cut would split a
247
+ * surrogate pair — a lone high surrogate is not valid Unicode, and a strict
248
+ * OTLP/JSON decoder rejects it.
249
+ */
250
+ function cutAt(s, n) {
251
+ const code = s.charCodeAt(n - 1);
252
+ return s.slice(0, code >= 0xd800 && code <= 0xdbff ? n - 1 : n);
253
+ }
254
+ /** The longest model- or caller-supplied text a span NAME carries. */
255
+ const MAX_SPAN_NAME_PART = 64;
256
+ /**
257
+ * A tool or model name as a span-name component. Span names never pass
258
+ * {@link capAttrs}, and a tool name is the MODEL's text when it names no
259
+ * registered tool — so it is capped here (registered names are at most 64
260
+ * characters by the providers' own rule, and pass unchanged).
261
+ */
262
+ function spanNamePart(name) {
263
+ return name.length > MAX_SPAN_NAME_PART ? `${cutAt(name, MAX_SPAN_NAME_PART - 1)}…` : name;
145
264
  }
146
265
  function boundList(items) {
147
266
  const capped = items.slice(0, MAX_LIST_ITEMS).map(bound);
@@ -149,6 +268,49 @@ function boundList(items) {
149
268
  ? [...capped, `…+${items.length - MAX_LIST_ITEMS} more`]
150
269
  : capped;
151
270
  }
271
+ /**
272
+ * The CONTENT attributes — the ones a content switch opened on purpose, each
273
+ * with its own size rule (tool content and the explanation: omitted over
274
+ * `maxContentChars`, never cut; `gen_ai.task.*`: exported as they always
275
+ * were). Every OTHER string leaving this adapter is capped by
276
+ * {@link capAttrs}.
277
+ */
278
+ const CONTENT_KEYS = new Set([
279
+ 'gen_ai.task.input',
280
+ 'gen_ai.task.output',
281
+ 'gen_ai.tool.call.arguments',
282
+ 'gen_ai.tool.call.result',
283
+ 'agentfootprint.tool.args.content',
284
+ 'agentfootprint.tool.result.content',
285
+ 'gen_ai.evaluation.explanation',
286
+ ]);
287
+ /**
288
+ * The ONE cap on the way out: every string attribute — span, span event, the
289
+ * flattened fallback — at most {@link MAX_ATTR_CHARS}, every string list at
290
+ * most {@link MAX_LIST_ITEMS} items, except {@link CONTENT_KEYS}. The
291
+ * projections bound what they build; this funnel is what makes "every
292
+ * exported string is capped" true of fields nobody thought to bound — a
293
+ * caller-sized session id, a principal, a label. Returns the SAME object
294
+ * when nothing needed a cap, so an unaffected span is byte-identical.
295
+ */
296
+ function capAttrs(attrs) {
297
+ let out;
298
+ for (const [key, value] of Object.entries(attrs)) {
299
+ if (CONTENT_KEYS.has(key))
300
+ continue;
301
+ let capped = value;
302
+ if (typeof value === 'string' && value.length > MAX_ATTR_CHARS)
303
+ capped = bound(value);
304
+ else if (Array.isArray(value) &&
305
+ (value.length > MAX_LIST_ITEMS || value.some((item) => item.length > MAX_ATTR_CHARS)))
306
+ capped = boundList(value);
307
+ if (capped !== value) {
308
+ out ??= { ...attrs };
309
+ out[key] = capped;
310
+ }
311
+ }
312
+ return out ?? attrs;
313
+ }
152
314
  /** Render one rule's operator-level conditions as compact strings:
153
315
  * `creditScore gt 700 → 750 (true)`. Value summaries come from the
154
316
  * engine already bounded + redaction-aware — we only re-cap length. */
@@ -193,6 +355,218 @@ function looksLikeDecideEvidence(value) {
193
355
  value !== null &&
194
356
  Array.isArray(value.rules));
195
357
  }
358
+ /** Name the closed standing vocabulary once — the per-turn counts key on it. */
359
+ const STANDINGS = ['fact', 'open', 'noise', 'ruled-out'];
360
+ /**
361
+ * Standing span events written per turn. The MODEL chooses how many standing
362
+ * rows it files, and a real OTel SDK keeps 128 events per span by evicting
363
+ * the OLDEST: unbounded rows would let a model (or a prompt injection in a
364
+ * tool result) push the span's earlier record — its route decision — off
365
+ * the wire. Past the cap, rows are counted and summarized once.
366
+ */
367
+ const MAX_STANDING_EVENTS_PER_TURN = 32;
368
+ /** The default ceiling on one exported CONTENT value, in characters. */
369
+ const DEFAULT_MAX_CONTENT_CHARS = 65_536;
370
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
371
+ /** Text that is a serialized plain object — the "serialized string" the spec
372
+ * says to take as the object it encodes. Only text opening with `{` is
373
+ * parsed; the caller has already refused anything over the ceiling. */
374
+ function isSerializedObject(text) {
375
+ if (!text.trimStart().startsWith('{'))
376
+ return false;
377
+ try {
378
+ return isPlainObject(JSON.parse(text));
379
+ }
380
+ catch {
381
+ return false;
382
+ }
383
+ }
384
+ /**
385
+ * A tool call's arguments or result, as this adapter may export it:
386
+ *
387
+ * - a plain object, or text that parses to one → the spec's
388
+ * `gen_ai.tool.call.arguments` / `.result`, as a JSON string (the spec's
389
+ * span form: its schema is `{"type": "object"}`, and OTel JS span
390
+ * attributes hold no objects);
391
+ * - anything else — plain text, a number, an array, the string `"123"` —
392
+ * → `agentfootprint.tool.<args|result>.content`: text verbatim, other
393
+ * values as JSON. Never the spec attribute, so a string is never
394
+ * exported as the number it spells;
395
+ * - over `maxChars` → neither: `agentfootprint.tool.<…>.omitted_chars`
396
+ * states the size. Never a cut value, which reads as complete data;
397
+ * - nothing serializable (undefined, a function, a cycle, a BigInt) → {}.
398
+ */
399
+ function toolContentAttrs(kind, value, maxChars) {
400
+ const specKey = kind === 'args' ? 'gen_ai.tool.call.arguments' : 'gen_ai.tool.call.result';
401
+ if (value === undefined)
402
+ return {};
403
+ let text;
404
+ if (typeof value === 'string')
405
+ text = value;
406
+ else {
407
+ try {
408
+ text = JSON.stringify(value);
409
+ }
410
+ catch {
411
+ return {};
412
+ }
413
+ }
414
+ if (text === undefined)
415
+ return {};
416
+ if (text.length > maxChars)
417
+ return { [`agentfootprint.tool.${kind}.omitted_chars`]: text.length };
418
+ const object = typeof value === 'string' ? isSerializedObject(value) : isPlainObject(value);
419
+ return { [object ? specKey : `agentfootprint.tool.${kind}.content`]: text };
420
+ }
421
+ const countOf = (list) => (Array.isArray(list) ? list.length : 0);
422
+ /**
423
+ * `tools.absent` / `tools.coverage_declared` — a COUNT per declared list, and
424
+ * the suggested tool's NAME only when `knownTool` recognizes it as a tool this
425
+ * strategy has seen RUN (a call that returned without error — it is held, by
426
+ * construction). Never a sentence: `looked_for`, `what`, `why`, `tryInstead` —
427
+ * and an unrecognized `tryInsteadTool.tool`, which is as much author text as
428
+ * the rest — can each interpolate an argument (coverage README, "What that
429
+ * does not close"); an unrecognized suggestion says only that it exists. A
430
+ * registered tool the agent has not run yet is not recognized: the event
431
+ * carries no roster (that would change every `absent()` event's bytes), so
432
+ * this may omit a real name — never export text that is not one.
433
+ */
434
+ function coverageAttrs(p, knownTool) {
435
+ const suggested = typeof p.tryInsteadTool?.tool === 'string';
436
+ return {
437
+ ...(typeof p.toolName === 'string' && { 'gen_ai.tool.name': p.toolName }),
438
+ ...(typeof p.toolCallId === 'string' && { 'gen_ai.tool.call.id': p.toolCallId }),
439
+ ...(typeof p.iteration === 'number' && { 'agentfootprint.iteration.index': p.iteration }),
440
+ 'agentfootprint.coverage.checked.count': countOf(p.checked),
441
+ 'agentfootprint.coverage.not_checked.count': countOf(p.notChecked),
442
+ 'agentfootprint.coverage.cannot_cover.count': countOf(p.cannotCover),
443
+ ...(suggested &&
444
+ (knownTool(String(p.tryInsteadTool?.tool))
445
+ ? { 'agentfootprint.coverage.try_instead.tool': bound(p.tryInsteadTool?.tool) }
446
+ : { 'agentfootprint.coverage.try_instead.unregistered': true })),
447
+ };
448
+ }
449
+ /** `agent.evidence_checked` — the verdict and a COUNT; the unsupported
450
+ * values themselves only under `captureContent`. */
451
+ function evidenceVerdictAttrs(p, content) {
452
+ const unsupported = Array.isArray(p.unsupported) ? p.unsupported : [];
453
+ return {
454
+ ...(typeof p.action === 'string' && { 'agentfootprint.evidence.action': p.action }),
455
+ ...(typeof p.posture === 'string' && { 'agentfootprint.evidence.posture': p.posture }),
456
+ ...(typeof p.candidates === 'number' && { 'agentfootprint.evidence.candidates': p.candidates }),
457
+ 'agentfootprint.evidence.unsupported.count': unsupported.length,
458
+ ...(typeof p.afterRevision === 'boolean' && {
459
+ 'agentfootprint.evidence.after_revision': p.afterRevision,
460
+ }),
461
+ ...(p.evidenceTruncated === true && { 'agentfootprint.evidence.truncated': true }),
462
+ ...(typeof p.iteration === 'number' && { 'agentfootprint.iteration.index': p.iteration }),
463
+ ...(content &&
464
+ unsupported.length > 0 && {
465
+ 'agentfootprint.evidence.unsupported.values': boundList(unsupported.map((u) => String(u.value))),
466
+ }),
467
+ };
468
+ }
469
+ /**
470
+ * `findings.standing` — ids, the enum and counts. The assertions, `line` and
471
+ * every other word the model wrote stay in the committed ledger. An
472
+ * `unknownId` row exports the FLAG only: its `toolCallId` is a string the
473
+ * MODEL wrote that names no result the run returned — model text, and
474
+ * nothing any reader could join on.
475
+ */
476
+ function standingAttrs(p) {
477
+ const knownId = p.unknownId !== true && typeof p.toolCallId === 'string';
478
+ return {
479
+ ...(typeof p.standing === 'string' && { 'agentfootprint.findings.standing': p.standing }),
480
+ ...(knownId && { 'gen_ai.tool.call.id': bound(p.toolCallId) }),
481
+ ...(typeof p.toolName === 'string' && { 'gen_ai.tool.name': p.toolName }),
482
+ ...(typeof p.declaredOn === 'string' && {
483
+ 'agentfootprint.findings.declared_on': p.declaredOn,
484
+ }),
485
+ ...(typeof p.assertionCount === 'number' && {
486
+ 'agentfootprint.findings.assertion_count': p.assertionCount,
487
+ }),
488
+ ...(Array.isArray(p.conflictKeys) && {
489
+ 'agentfootprint.findings.conflict_count': p.conflictKeys.length,
490
+ }),
491
+ ...(p.unknownId === true && { 'agentfootprint.findings.unknown_id': true }),
492
+ ...(typeof p.agrees === 'boolean' && { 'agentfootprint.findings.agrees': p.agrees }),
493
+ ...(typeof p.iteration === 'number' && { 'agentfootprint.iteration.index': p.iteration }),
494
+ };
495
+ }
496
+ /** One count per standing in the closed vocabulary — zeros included, since
497
+ * the adapter saw every standing row the traced turn filed. */
498
+ function standingCountAttrs(counts) {
499
+ const attrs = {};
500
+ for (const s of STANDINGS)
501
+ attrs[`agentfootprint.findings.${s.replace('-', '_')}.count`] = counts.get(s) ?? 0;
502
+ return attrs;
503
+ }
504
+ /** `integrity.disposition` rows summed — the "did any check fire?" a reader
505
+ * filters on. Synthetic (canary) counts stay out, as they do on each row. */
506
+ function dispositionSpanAttrs(p) {
507
+ const rows = Array.isArray(p.rows) ? p.rows : [];
508
+ const sum = (key) => rows.reduce((n, row) => n + (typeof row[key] === 'number' ? row[key] : 0), 0);
509
+ return {
510
+ ...(typeof p.posture === 'string' && { 'agentfootprint.integrity.posture': p.posture }),
511
+ ...(typeof p.workExisted === 'boolean' && {
512
+ 'agentfootprint.integrity.work_existed': p.workExisted,
513
+ }),
514
+ 'agentfootprint.integrity.checked': sum('checked'),
515
+ 'agentfootprint.integrity.findings': sum('findings'),
516
+ 'agentfootprint.integrity.not_applicable': sum('notApplicable'),
517
+ 'agentfootprint.integrity.unreachable': sum('unreachable'),
518
+ };
519
+ }
520
+ /** One registered (check, seam) row — the check's name and its outcomes. */
521
+ function dispositionRowAttrs(row) {
522
+ const n = (v) => (typeof v === 'number' ? v : 0);
523
+ return {
524
+ 'agentfootprint.integrity.check.name': bound(row.check),
525
+ 'agentfootprint.integrity.check.seam': bound(row.seam),
526
+ 'agentfootprint.integrity.check.checked': n(row.checked),
527
+ 'agentfootprint.integrity.check.findings': n(row.findings),
528
+ 'agentfootprint.integrity.check.not_applicable': n(row.notApplicable),
529
+ 'agentfootprint.integrity.check.unreachable': n(row.unreachable),
530
+ 'agentfootprint.integrity.check.synthetic': n(row.synthetic),
531
+ };
532
+ }
533
+ /**
534
+ * `eval.score` → the GenAI semconv `gen_ai.evaluation.result` attributes
535
+ * (github.com/open-telemetry/semantic-conventions-genai,
536
+ * docs/gen-ai/gen-ai-events.md). The spec has no field for the target or the
537
+ * evaluator kind, so those ride this adapter's own names — and only as words
538
+ * of the payload's own closed vocabularies (a TypeScript union is not a
539
+ * runtime check; a hand-fed sentence in `target` is not exported). The
540
+ * explanation is content (a judge's reason can quote what it graded):
541
+ * `captureContent` only, and omitted — its size stated — over `maxChars`.
542
+ * `evidence` is a free-form record and is never exported.
543
+ */
544
+ function evaluationAttrs(p, content, maxChars) {
545
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
546
+ const explanation = content && typeof p.explanation === 'string' ? p.explanation : undefined;
547
+ return {
548
+ 'gen_ai.evaluation.name': bound(p.metricId),
549
+ ...(finite(p.value) && { 'gen_ai.evaluation.score.value': p.value }),
550
+ ...(typeof p.label === 'string' && { 'gen_ai.evaluation.score.label': bound(p.label) }),
551
+ ...(explanation !== undefined &&
552
+ (explanation.length > maxChars
553
+ ? { 'agentfootprint.eval.explanation.omitted_chars': explanation.length }
554
+ : { 'gen_ai.evaluation.explanation': explanation })),
555
+ ...(EVAL_TARGETS.has(p.target) && {
556
+ 'agentfootprint.eval.target': p.target,
557
+ }),
558
+ ...(typeof p.targetRef === 'string' && {
559
+ 'agentfootprint.eval.target_ref': bound(p.targetRef),
560
+ }),
561
+ ...(finite(p.threshold) && { 'agentfootprint.eval.threshold': p.threshold }),
562
+ ...(EVALUATORS.has(p.evaluator) && {
563
+ 'agentfootprint.eval.evaluator': p.evaluator,
564
+ }),
565
+ };
566
+ }
567
+ /** `EvalScorePayload`'s closed vocabularies, checked at run time. */
568
+ const EVAL_TARGETS = new Set(['iteration', 'turn', 'run', 'toolCall']);
569
+ const EVALUATORS = new Set(['llm', 'fn', 'heuristic']);
196
570
  // ─── Strategy factory ────────────────────────────────────────────────
197
571
  export function otelObservability(opts) {
198
572
  if (!opts.serviceName) {
@@ -204,6 +578,10 @@ export function otelObservability(opts) {
204
578
  const explainability = opts.explainability !== false;
205
579
  const scopeName = opts.scopeName ?? 'agentfootprint';
206
580
  const captureContent = opts.captureContent === true;
581
+ const captureToolContent = opts.captureToolContent === true;
582
+ const maxContentChars = opts.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS;
583
+ const rawConversationId = opts.conversationId === 'raw';
584
+ const allCheckRows = opts.checkAccounting === 'all';
207
585
  // Lazy-resolve tracer if not injected. Defer the API import until
208
586
  // first event so consumers who don't actually fire events (no agent
209
587
  // run yet) don't even hit the OTel API surface.
@@ -231,12 +609,101 @@ export function otelObservability(opts) {
231
609
  return tracer;
232
610
  }
233
611
  const activeTurns = new Map();
612
+ /**
613
+ * Tool names this strategy has seen RUN — a `tool_end` without `error` or
614
+ * `notExecuted` — so a held tool, by construction. What a `tryInsteadTool`
615
+ * suggestion is recognized against. Bounded; names only.
616
+ */
617
+ const ranTools = new Set();
618
+ /**
619
+ * Tool names that filed a `tools.code_run` — a code runner. Its arguments
620
+ * ARE the generated program, which "must not reach an exporter"
621
+ * (`events/payloads.ts` · `ToolsCodeRunPayload`): under
622
+ * `captureToolContent` its calls export no arguments. `code_run` fires after
623
+ * the tool ran and BEFORE its `tool_end`, so the first call is covered too;
624
+ * a call that failed exports no content at all.
625
+ */
626
+ const codeRunners = new Set();
627
+ const MAX_TOOL_NAMES = 1024;
628
+ function remember(set, name) {
629
+ if (set.size >= MAX_TOOL_NAMES && !set.has(name))
630
+ return;
631
+ set.add(name);
632
+ }
633
+ /**
634
+ * Turns that have CLOSED (turn_end / error.fatal), by run id — so a late
635
+ * event can still find its run. Two events arrive late by construction: the
636
+ * checker accounting (`integrity.disposition`, filed in the run door's
637
+ * `finally`, after turn_end) and an evaluation scored after the run. A real
638
+ * SDK drops span events on an ENDED span, so a late event gets its own
639
+ * short child span. An unsampled turn is remembered too (no span), so its
640
+ * late events are dropped with it rather than exported loose.
641
+ *
642
+ * Bounded — the last {@link MAX_CLOSED_TURNS} — and cleared by `stop()`.
643
+ * The window is per strategy, and one strategy usually serves the whole
644
+ * process: in a busy server a late event for a run 32 turns back finds it
645
+ * forgotten. A forgotten run's id is still remembered (a string, in
646
+ * {@link forgottenRuns}), so "forgotten" is told apart from "never traced"
647
+ * and reported, never dropped in silence. Each held root keeps its span
648
+ * object (and, under `captureContent`, the task text on it) on the heap
649
+ * until it leaves the window.
650
+ */
651
+ const MAX_CLOSED_TURNS = 32;
652
+ const MAX_FORGOTTEN_RUNS = 1024;
653
+ const closedTurns = new Map();
654
+ /** run id → whether it was sampled, for runs that left the window. */
655
+ const forgottenRuns = new Map();
656
+ function rememberClosed(runId, t) {
657
+ closedTurns.delete(runId);
658
+ closedTurns.set(runId, {
659
+ ...(t.root !== undefined && { root: t.root }),
660
+ ...(t.sessionDigest !== undefined && { sessionDigest: t.sessionDigest }),
661
+ });
662
+ while (closedTurns.size > MAX_CLOSED_TURNS) {
663
+ const [oldest] = closedTurns.entries();
664
+ if (oldest === undefined)
665
+ break;
666
+ closedTurns.delete(oldest[0]);
667
+ forgottenRuns.set(oldest[0], oldest[1].root !== undefined);
668
+ }
669
+ while (forgottenRuns.size > MAX_FORGOTTEN_RUNS) {
670
+ const [oldest] = forgottenRuns.keys();
671
+ if (oldest === undefined)
672
+ break;
673
+ forgottenRuns.delete(oldest);
674
+ }
675
+ }
676
+ /** `gen_ai.conversation.id` for a span of this turn — or nothing. */
677
+ function conversationAttr(t) {
678
+ return t.conversationId !== undefined ? { 'gen_ai.conversation.id': t.conversationId } : {};
679
+ }
680
+ /** `gen_ai.task.<input|output>`, or its size when over `maxContentChars`. */
681
+ function taskContent(kind, text) {
682
+ return text.length > maxContentChars
683
+ ? { [`agentfootprint.task.${kind}.omitted_chars`]: text.length }
684
+ : { [`gen_ai.task.${kind}`]: text };
685
+ }
686
+ /** A non-empty `meta.sessionId`, or undefined. */
687
+ function sessionOf(event) {
688
+ const sessionId = event.meta?.sessionId;
689
+ return typeof sessionId === 'string' && sessionId !== '' ? sessionId : undefined;
690
+ }
234
691
  let stopped = false;
235
692
  // The fallback when the consumer wires nothing. Rate-limited; a
236
693
  // consumer-supplied sink is not. Armed at CONSTRUCTION — before 8.11.0 it
237
694
  // was installed lazily inside `_onError` itself, which meant any caller
238
695
  // reading the hook rather than calling the method found `undefined`.
239
696
  const consoleSink = rateLimitedConsoleSink('otel');
697
+ /** A failure this adapter must not swallow — through the strategy's own
698
+ * `_onError`, so a hook assigned after construction is the one called. */
699
+ function report(error, event) {
700
+ try {
701
+ strategy._onError?.(error, event);
702
+ }
703
+ catch {
704
+ /* a failing sink must never break the agent loop */
705
+ }
706
+ }
240
707
  /**
241
708
  * Resolve the run anchor for an event.
242
709
  *
@@ -251,21 +718,46 @@ export function otelObservability(opts) {
251
718
  const meta = event.meta;
252
719
  return meta?.runId ?? event.payload?.runId;
253
720
  }
254
- function pushSpan(turnState, name, attrs) {
255
- // OTel parent-context wiring: we capture the parent in a context
256
- // and start the new span under it. (For BYO SDK setups, the
257
- // `trace.setSpan` + `context.with` pattern is canonical. For
258
- // the test-injected tracer path, we just pass the parent as
259
- // implicit context.)
260
- const parent = turnState.stack[turnState.stack.length - 1]?.span;
721
+ /**
722
+ * Start a span under `parent` (a root when `parent` is undefined) — the ONE
723
+ * place spans are started. OTel parent-context wiring: we capture the
724
+ * parent in a context and start the new span under it. (For BYO SDK
725
+ * setups, the `trace.setSpan` + `context.with` pattern is canonical. For
726
+ * the test-injected tracer path, we just pass the parent as implicit
727
+ * context.) The parent may already have ENDED: OTel links a child by span
728
+ * context, so a span started after its parent closed is still its child.
729
+ */
730
+ function startSpanUnder(parent, name, attrs, root = false) {
261
731
  let ctx;
262
732
  if (parent && otelApi?.trace?.setSpan && otelApi?.context?.active) {
263
733
  ctx = otelApi.trace.setSpan(otelApi.context.active(), parent);
264
734
  }
265
- const span = ensureTracer().startSpan(name, attrs ? { attributes: attrs } : undefined, ctx);
735
+ const options = attrs || root
736
+ ? { ...(attrs && { attributes: capAttrs(attrs) }), ...(root && { root: true }) }
737
+ : undefined;
738
+ return ensureTracer().startSpan(name, options, ctx);
739
+ }
740
+ function pushSpan(turnState, name, attrs) {
741
+ const span = startSpanUnder(turnState.stack[turnState.stack.length - 1]?.span, name, attrs);
266
742
  turnState.stack.push({ name, span });
267
743
  return span;
268
744
  }
745
+ /** A short span under `parent` carrying `events`, ended at once — how an
746
+ * event lands under a turn it can no longer be written onto. */
747
+ function recordOnChildSpan(parent, name, attrs, events) {
748
+ const span = startSpanUnder(parent, name, attrs);
749
+ for (const e of events)
750
+ recordSpanEvent(span, e.name, e.attrs);
751
+ endSpan(span);
752
+ }
753
+ /** The root of a traced turn, open or recently closed — `undefined` for an
754
+ * unsampled turn or a run this adapter never traced (or has forgotten). */
755
+ function rootOf(runId) {
756
+ const live = activeTurns.get(runId);
757
+ if (live !== undefined)
758
+ return live.root;
759
+ return closedTurns.get(runId)?.root;
760
+ }
269
761
  function popSpan(turnState, match) {
270
762
  let idx = turnState.stack.length - 1;
271
763
  if (match !== undefined) {
@@ -294,6 +786,10 @@ export function otelObservability(opts) {
294
786
  span.end();
295
787
  }
296
788
  function setAttrs(span, attrs) {
789
+ writeAttrs(span, capAttrs(attrs));
790
+ }
791
+ /** The raw write, for attributes already through {@link capAttrs}. */
792
+ function writeAttrs(span, attrs) {
297
793
  for (const [key, value] of Object.entries(attrs)) {
298
794
  try {
299
795
  span.setAttribute(key, value);
@@ -306,9 +802,12 @@ export function otelObservability(opts) {
306
802
  /** Emit a span event (preferred) or flattened-attribute fallback —
307
803
  * see "Decisions = SPAN EVENTS" in the module docs. */
308
804
  function recordSpanEvent(span, name, attrs) {
805
+ // Capped BEFORE flattening: the fallback prefixes every key, and a content
806
+ // key must still be recognized as one.
807
+ const capped = capAttrs(attrs);
309
808
  if (typeof span.addEvent === 'function') {
310
809
  try {
311
- span.addEvent(name, attrs);
810
+ span.addEvent(name, capped);
312
811
  return;
313
812
  }
314
813
  catch {
@@ -316,9 +815,9 @@ export function otelObservability(opts) {
316
815
  }
317
816
  }
318
817
  const flattened = {};
319
- for (const [key, value] of Object.entries(attrs))
818
+ for (const [key, value] of Object.entries(capped))
320
819
  flattened[`${name}.${key}`] = value;
321
- setAttrs(span, flattened);
820
+ writeAttrs(span, flattened);
322
821
  }
323
822
  function topSpan(t) {
324
823
  return t?.stack[t.stack.length - 1]?.span;
@@ -463,10 +962,285 @@ export function otelObservability(opts) {
463
962
  });
464
963
  break;
465
964
  }
965
+ // A tool's declared absence / coverage — on the call's OWN span while it
966
+ // is open (parallel calls interleave, so the stack top may be another
967
+ // call's), else the active span. Counts per list, never a sentence.
968
+ case 'agentfootprint.tools.absent':
969
+ case 'agentfootprint.tools.coverage_declared': {
970
+ const callId = typeof p.toolCallId === 'string' ? p.toolCallId : undefined;
971
+ const span = (callId !== undefined ? t.toolSpans.get(callId) : undefined) ?? top;
972
+ recordSpanEvent(span, event.type, coverageAttrs(p, (name) => ranTools.has(name)));
973
+ break;
974
+ }
975
+ // The evidence gate's verdict on one would-be-final answer.
976
+ case 'agentfootprint.agent.evidence_checked': {
977
+ recordSpanEvent(top, event.type, evidenceVerdictAttrs(p, captureContent));
978
+ break;
979
+ }
980
+ // One model standing on one earlier result; counted for turn_end. The
981
+ // MODEL decides how many rows it files, and a real SDK keeps 128
982
+ // events per span by evicting the OLDEST — so past a per-turn cap the
983
+ // rows are counted, not written, and one summary event says how many
984
+ // at the turn's close. The per-standing counts stay complete.
985
+ case 'agentfootprint.findings.standing': {
986
+ const row = p;
987
+ if (t.standingEvents < MAX_STANDING_EVENTS_PER_TURN) {
988
+ t.standingEvents += 1;
989
+ recordSpanEvent(top, event.type, standingAttrs(row));
990
+ }
991
+ else
992
+ t.standingOmitted += 1;
993
+ if (row.standing !== undefined && STANDINGS.includes(row.standing)) {
994
+ t.standingCounts ??= new Map();
995
+ t.standingCounts.set(row.standing, (t.standingCounts.get(row.standing) ?? 0) + 1);
996
+ }
997
+ break;
998
+ }
466
999
  default:
467
1000
  break;
468
1001
  }
469
1002
  }
1003
+ /**
1004
+ * The turn's findings summary on the agent span, at whichever close the
1005
+ * turn gets (turn_end, error.fatal, or `stop()` for a paused one): a count
1006
+ * per standing when any row was filed, and the one overflow event when the
1007
+ * per-turn cap held rows back.
1008
+ */
1009
+ function flushStandings(t) {
1010
+ if (t.root === undefined || !explainability)
1011
+ return;
1012
+ if (t.standingCounts !== undefined)
1013
+ setAttrs(t.root, standingCountAttrs(t.standingCounts));
1014
+ if (t.standingOmitted > 0) {
1015
+ recordSpanEvent(t.root, 'agentfootprint.findings.standing_overflow', {
1016
+ 'agentfootprint.findings.standing_events_omitted': t.standingOmitted,
1017
+ 'agentfootprint.findings.standing_events_written': t.standingEvents,
1018
+ });
1019
+ }
1020
+ }
1021
+ /** The proposal as `tool_start` carried it, serialized THEN — a snapshot, so
1022
+ * nothing written into the object later can reach the export. */
1023
+ function snapshotArgs(toolName, args) {
1024
+ let text;
1025
+ try {
1026
+ text = JSON.stringify(args);
1027
+ }
1028
+ catch {
1029
+ text = undefined;
1030
+ }
1031
+ if (text === undefined)
1032
+ return { toolName, chars: 0 };
1033
+ return text.length > maxContentChars
1034
+ ? { toolName, chars: text.length }
1035
+ : { toolName, text, chars: text.length };
1036
+ }
1037
+ /**
1038
+ * `captureToolContent`, for a call that RAN and RETURNED (no `error`, no
1039
+ * `notExecuted`): its arguments — the proposal snapshot, with every key a
1040
+ * rule changed or the tool's redaction hides (`changedArgKeys`) shown as
1041
+ * `'REDACTED'`: withheld, never replaced by a value the event does not
1042
+ * carry — and what the model READ (`modelResult` when a rule changed it,
1043
+ * else `result`). A code runner's arguments are its generated program and
1044
+ * are withheld. A call that failed or never ran exports no content. Shape
1045
+ * and size by {@link toolContentAttrs}.
1046
+ */
1047
+ function toolContent(t, p, errored) {
1048
+ const proposal = p.toolCallId !== undefined ? t.proposedArgs.get(p.toolCallId) : undefined;
1049
+ if (p.toolCallId !== undefined)
1050
+ t.proposedArgs.delete(p.toolCallId);
1051
+ if (errored || p.notExecuted === true)
1052
+ return {};
1053
+ const read = Object.prototype.hasOwnProperty.call(p, 'modelResult') ? p.modelResult : p.result;
1054
+ return {
1055
+ ...argsContent(proposal, p.changedArgKeys ?? []),
1056
+ ...toolContentAttrs('result', read, maxContentChars),
1057
+ };
1058
+ }
1059
+ function argsContent(proposal, changed) {
1060
+ if (proposal === undefined)
1061
+ return {};
1062
+ if (codeRunners.has(proposal.toolName))
1063
+ return { 'agentfootprint.tool.args.withheld': 'code-runner' };
1064
+ if (proposal.text === undefined)
1065
+ return proposal.chars > 0 ? { 'agentfootprint.tool.args.omitted_chars': proposal.chars } : {};
1066
+ if (changed.length === 0)
1067
+ return toolContentAttrs('args', proposal.text, maxContentChars);
1068
+ let parsed;
1069
+ try {
1070
+ parsed = JSON.parse(proposal.text);
1071
+ }
1072
+ catch {
1073
+ return {};
1074
+ }
1075
+ if (!isPlainObject(parsed))
1076
+ return {};
1077
+ const shown = { ...parsed };
1078
+ for (const key of changed)
1079
+ shown[key] = 'REDACTED';
1080
+ return toolContentAttrs('args', shown, maxContentChars);
1081
+ }
1082
+ // ─── Late-capable events (they may arrive after the turn closed) ───
1083
+ /**
1084
+ * The checker accounting, filed ONCE per run from the run door's `finally`
1085
+ * — after turn_end on a finished run, after error.fatal on a failed one,
1086
+ * with the turn still open on a paused one. When it is exported it gets
1087
+ * its own short span under the turn's root, so a reader finds it in one
1088
+ * place whichever exit the run took.
1089
+ *
1090
+ * By default only when a row is ACTIONABLE — a check filed findings, or a
1091
+ * registered check filed nothing while the run did work (the wiring rot
1092
+ * `integrity/disposition/ledger.ts` · `filedNothing` names) — and only those
1093
+ * rows ride as events. A healthy run exports nothing. `checkAccounting:
1094
+ * 'all'` exports every row on every run.
1095
+ */
1096
+ function handleDisposition(event, runId) {
1097
+ const root = rootOf(runId);
1098
+ if (root === undefined)
1099
+ return;
1100
+ const p = event.payload;
1101
+ const rows = Array.isArray(p.rows) ? p.rows : [];
1102
+ const n = (v) => (typeof v === 'number' ? v : 0);
1103
+ const actionable = (row) => n(row.findings) > 0 ||
1104
+ (p.workExisted === true &&
1105
+ filedNothing({
1106
+ checked: n(row.checked),
1107
+ notApplicable: n(row.notApplicable),
1108
+ unreachable: n(row.unreachable),
1109
+ }));
1110
+ const shown = allCheckRows ? rows : rows.filter(actionable);
1111
+ if (!allCheckRows && shown.length === 0)
1112
+ return;
1113
+ recordOnChildSpan(root, 'agentfootprint.integrity.disposition', dispositionSpanAttrs(p), shown.map((row) => ({
1114
+ name: 'agentfootprint.integrity.check',
1115
+ attrs: dispositionRowAttrs(row),
1116
+ })));
1117
+ }
1118
+ /** A score about a whole run can name it: `target` 'run' / 'turn' with the
1119
+ * run id as `targetRef` — the only join a score filed after the run has
1120
+ * (`agent.emit` stamps `runId: 'consumer-scope'`). A tool-call or
1121
+ * iteration ref names something else and never joins a run. */
1122
+ function runNamedBy(p) {
1123
+ return (p.target === 'run' || p.target === 'turn') && typeof p.targetRef === 'string'
1124
+ ? p.targetRef
1125
+ : undefined;
1126
+ }
1127
+ function heldRun(runId) {
1128
+ const live = activeTurns.get(runId);
1129
+ if (live !== undefined)
1130
+ return {
1131
+ runId,
1132
+ live,
1133
+ ...(live.root !== undefined && { root: live.root }),
1134
+ ...(live.sessionDigest !== undefined && { sessionDigest: live.sessionDigest }),
1135
+ };
1136
+ const closed = closedTurns.get(runId);
1137
+ return closed === undefined ? undefined : { runId, ...closed };
1138
+ }
1139
+ /**
1140
+ * Where a verified score lands on a run whose turn is still OPEN: on the
1141
+ * span it names while that span is open — the tool call (`targetRef` = its
1142
+ * call id), the iteration (`targetRef` = its index) — else on a short span
1143
+ * of its own under the turn's root, never on a sibling operation it does
1144
+ * not name. A score about the run or the turn lands on the agent span.
1145
+ */
1146
+ function placeOnOpenTurn(t, root, p, attrs) {
1147
+ const ref = typeof p.targetRef === 'string' ? p.targetRef : undefined;
1148
+ let span;
1149
+ if (p.target === 'toolCall')
1150
+ span = ref !== undefined ? t.toolSpans.get(ref) : undefined;
1151
+ else if (p.target === 'iteration')
1152
+ span = t.stack.find((entry) => entry.name === `iteration:${ref ?? ''}`)?.span;
1153
+ else
1154
+ span = root;
1155
+ if (span !== undefined)
1156
+ recordSpanEvent(span, 'gen_ai.evaluation.result', attrs);
1157
+ else
1158
+ recordOnChildSpan(root, 'agentfootprint.evaluation', undefined, [
1159
+ { name: 'gen_ai.evaluation.result', attrs },
1160
+ ]);
1161
+ }
1162
+ /** A score this adapter may not (or cannot) place under a run: its own
1163
+ * root span, the ref it named as an attribute, and why it stands alone. */
1164
+ function recordUnparented(attrs, reason) {
1165
+ // `root`: a NEW trace, never a child of whatever span is active in the
1166
+ // caller's context (an unrelated request being served) — unparented means
1167
+ // unparented.
1168
+ const span = startSpanUnder(undefined, 'agentfootprint.evaluation', { 'service.name': opts.serviceName, 'agentfootprint.eval.unparented': reason }, true);
1169
+ recordSpanEvent(span, 'gen_ai.evaluation.result', attrs);
1170
+ endSpan(span);
1171
+ }
1172
+ /**
1173
+ * `eval.score` → `gen_ai.evaluation.result` (the spec's evaluation event,
1174
+ * carried here as a SPAN event: this adapter speaks only the Tracer API).
1175
+ *
1176
+ * WHICH run: the one the score NAMES (`target` 'run' / 'turn', `targetRef`
1177
+ * = a run id) when this adapter holds it; else the run the score was
1178
+ * emitted from. A ref that names no run held here ('this-run', a label)
1179
+ * falls back to the emitting run.
1180
+ *
1181
+ * WHETHER it may be parented there: only when the library can place the
1182
+ * score in that run's session — it was emitted from inside that very run,
1183
+ * or its own `meta.sessionId` is that run's session. Run ids are minted in
1184
+ * sequence and anyone can write one, so a score that cannot show it
1185
+ * belongs (every `agent.emit` after the run: no session on its meta) is
1186
+ * exported on a span of its OWN, unparented, naming the ref — never under
1187
+ * someone else's trace. Placement while the turn is open follows
1188
+ * {@link placeOnOpenTurn}; after it closed, a short `agentfootprint.evaluation`
1189
+ * span under the root (a finished span takes no events).
1190
+ *
1191
+ * Never silently lost: a run sampling dropped takes its scores with it; a
1192
+ * run that left the closed-turn window is exported unparented AND reported
1193
+ * through `onError`; a score with no `metricId` (the spec's one Required
1194
+ * attribute) is reported. This path is best effort — the durable home for
1195
+ * scores filed after the run is a score record, not a trace span.
1196
+ */
1197
+ function handleEvalScore(event, runId) {
1198
+ const p = event.payload;
1199
+ if (typeof p.metricId !== 'string' || p.metricId === '') {
1200
+ report(new Error('otelObservability: an agentfootprint.eval.score without a metricId was not exported — ' +
1201
+ 'gen_ai.evaluation.name is the one attribute the spec requires.'), event);
1202
+ return;
1203
+ }
1204
+ const attrs = evaluationAttrs(p, captureContent, maxContentChars);
1205
+ const named = runNamedBy(p);
1206
+ const namedRun = named !== undefined ? heldRun(named) : undefined;
1207
+ // A ref naming a run this strategy FORGOT is still that run's score: it
1208
+ // never falls back to the run it was emitted from.
1209
+ const forgottenRef = namedRun === undefined && named !== undefined && forgottenRuns.has(named) ? named : undefined;
1210
+ const target = forgottenRef !== undefined ? undefined : namedRun ?? heldRun(runId);
1211
+ if (target === undefined) {
1212
+ const ref = forgottenRef ?? named ?? runId;
1213
+ const sampled = forgottenRuns.get(ref);
1214
+ if (sampled === false)
1215
+ return; // sampling dropped it; its scores go with it
1216
+ if (sampled === true) {
1217
+ recordUnparented(attrs, 'run-forgotten');
1218
+ report(new Error(`otelObservability: an evaluation score names run '${bound(ref)}', which closed more ` +
1219
+ `than ${String(MAX_CLOSED_TURNS)} turns ago on this strategy — exported on its own ` +
1220
+ `span, unparented.`), event);
1221
+ return;
1222
+ }
1223
+ recordUnparented(attrs, 'run-not-held');
1224
+ return;
1225
+ }
1226
+ if (target.root === undefined)
1227
+ return; // sampling dropped it
1228
+ const scoreSession = sessionOf(event);
1229
+ const verified = target.runId === runId ||
1230
+ (scoreSession !== undefined &&
1231
+ target.sessionDigest !== undefined &&
1232
+ sha256Hex(scoreSession) === target.sessionDigest);
1233
+ if (!verified) {
1234
+ recordUnparented(attrs, 'owner-unverified');
1235
+ return;
1236
+ }
1237
+ if (target.live !== undefined)
1238
+ placeOnOpenTurn(target.live, target.root, p, attrs);
1239
+ else
1240
+ recordOnChildSpan(target.root, 'agentfootprint.evaluation', undefined, [
1241
+ { name: 'gen_ai.evaluation.result', attrs },
1242
+ ]);
1243
+ }
470
1244
  // ─── Event-to-span dispatch ────────────────────────────────────────
471
1245
  function handleEvent(event) {
472
1246
  if (stopped)
@@ -477,8 +1251,39 @@ export function otelObservability(opts) {
477
1251
  switch (event.type) {
478
1252
  case 'agentfootprint.agent.turn_start': {
479
1253
  const sampled = sampleRate >= 1 || Math.random() < sampleRate;
480
- const turnState = { stack: [], sampled, toolSpans: new Map() };
1254
+ // The CONVERSATION this run belongs to: `meta.sessionId` (9.4.0),
1255
+ // stamped by `run(input, { sessionId })` and by `standingAgent` from
1256
+ // the request's own session — the one a `SessionLifecycle` store
1257
+ // (9.26.0) persists. It is the spec's `gen_ai.conversation.id` exactly:
1258
+ // "the unique identifier for a conversation (session, thread)", and
1259
+ // the spec rules out a stand-in ("a new UUID, a trace identifier …
1260
+ // SHOULD NOT be used"), so a run that is not session-bound gets none —
1261
+ // never the runId, which names ONE turn and would merge nothing and
1262
+ // split everything in a backend's session view.
1263
+ //
1264
+ // WRITTEN AS A DIGEST by default (`conversationId`): at a door with no
1265
+ // verifier the session id is the handle to the conversation, and a
1266
+ // trace backend is read by many more people than the browser that
1267
+ // minted it. The digest groups exactly as the id would; the raw id is
1268
+ // an opt-in for doors that verify who may open a conversation.
1269
+ const sessionId = sessionOf(event);
1270
+ const sessionDigest = sessionId !== undefined ? sha256Hex(sessionId) : undefined;
1271
+ const turnState = {
1272
+ stack: [],
1273
+ sampled,
1274
+ toolSpans: new Map(),
1275
+ toolNames: new Map(),
1276
+ standingEvents: 0,
1277
+ standingOmitted: 0,
1278
+ proposedArgs: new Map(),
1279
+ ...(sessionId !== undefined && {
1280
+ conversationId: rawConversationId ? sessionId : sessionDigest,
1281
+ sessionDigest,
1282
+ }),
1283
+ };
481
1284
  activeTurns.set(runId, turnState);
1285
+ closedTurns.delete(runId);
1286
+ forgottenRuns.delete(runId);
482
1287
  if (sampled) {
483
1288
  const turnIndex = event.payload.turnIndex;
484
1289
  // `invoke_agent` span per the GenAI agent-span conventions.
@@ -486,10 +1291,8 @@ export function otelObservability(opts) {
486
1291
  // required) are back-filled on the first llm_start — unknown here.
487
1292
  // `userPrompt` rides the span ONLY when the consumer asked for it
488
1293
  // (`captureContent`) — see that option for what enabling it exports.
489
- // We emit `agentfootprint.run.id` (not `gen_ai.conversation.id`):
490
- // a run is one turn, not a conversation/session — agentfootprint
491
- // has no session primitive yet, and mislabeling would corrupt
492
- // backends' session grouping.
1294
+ // `agentfootprint.run.id` names the run (one turn) beside the
1295
+ // conversation id above — one session, many runs.
493
1296
  // WHO the run is for (9.11.0). This adapter maps selected signals
494
1297
  // onto spans rather than serializing the envelope — unlike the
495
1298
  // file / CloudWatch / AgentCore sinks, which write
@@ -503,6 +1306,7 @@ export function otelObservability(opts) {
503
1306
  'service.name': opts.serviceName,
504
1307
  'gen_ai.operation.name': 'invoke_agent',
505
1308
  'gen_ai.agent.name': opts.serviceName,
1309
+ ...conversationAttr(turnState),
506
1310
  'agentfootprint.run.id': runId,
507
1311
  ...(typeof turnIndex === 'number' && { 'agentfootprint.turn.index': turnIndex }),
508
1312
  // Absent when nobody named one — an anonymous run gets no
@@ -513,7 +1317,7 @@ export function otelObservability(opts) {
513
1317
  ...(actor?.tenant !== undefined && { 'agentfootprint.tenant.id': actor.tenant }),
514
1318
  ...(captureContent &&
515
1319
  typeof event.payload.userPrompt === 'string' && {
516
- 'gen_ai.task.input': event.payload.userPrompt,
1320
+ ...taskContent('input', event.payload.userPrompt),
517
1321
  }),
518
1322
  });
519
1323
  }
@@ -529,7 +1333,8 @@ export function otelObservability(opts) {
529
1333
  const p = event.payload;
530
1334
  setAttrs(t.root, {
531
1335
  ...(captureContent &&
532
- typeof p.finalContent === 'string' && { 'gen_ai.task.output': p.finalContent }),
1336
+ typeof p.finalContent === 'string' &&
1337
+ taskContent('output', p.finalContent)),
533
1338
  ...(typeof p.totalInputTokens === 'number' && {
534
1339
  'gen_ai.usage.input_tokens': p.totalInputTokens,
535
1340
  }),
@@ -541,6 +1346,9 @@ export function otelObservability(opts) {
541
1346
  }),
542
1347
  });
543
1348
  }
1349
+ // A count per findings standing (only when the turn filed any) and the
1350
+ // overflow summary (only when the per-turn cap held rows back).
1351
+ flushStandings(t);
544
1352
  // Defensive: end everything still on the stack.
545
1353
  while (t.stack.length > 0) {
546
1354
  const span = popSpan(t);
@@ -548,6 +1356,7 @@ export function otelObservability(opts) {
548
1356
  endSpan(span);
549
1357
  }
550
1358
  activeTurns.delete(runId);
1359
+ rememberClosed(runId, t);
551
1360
  break;
552
1361
  }
553
1362
  case 'agentfootprint.agent.iteration_start': {
@@ -584,13 +1393,15 @@ export function otelObservability(opts) {
584
1393
  // unchanged — 'anthropic' / 'openai' / 'cohere' are already
585
1394
  // well-known semconv values; others ride as custom values (the
586
1395
  // spec permits them).
587
- pushSpan(t, genAiNames && p.model ? `chat ${p.model}` : 'llm', {
1396
+ pushSpan(t, genAiNames && p.model ? `chat ${spanNamePart(p.model)}` : 'llm', {
588
1397
  'gen_ai.operation.name': 'chat',
589
1398
  ...(p.model !== undefined && { 'gen_ai.request.model': p.model }),
590
1399
  ...(p.provider !== undefined && { 'gen_ai.provider.name': p.provider }),
591
1400
  ...(typeof p.temperature === 'number' && {
592
1401
  'gen_ai.request.temperature': p.temperature,
593
1402
  }),
1403
+ // Conditionally Required on inference spans "if available" (spec).
1404
+ ...conversationAttr(t),
594
1405
  });
595
1406
  // Back-fill the conditionally-required agent-span attrs now that
596
1407
  // the first inference call reveals provider + model.
@@ -666,20 +1477,30 @@ export function otelObservability(opts) {
666
1477
  break;
667
1478
  }
668
1479
  // Tool-execution span per GenAI semconv (`execute_tool`).
669
- // Args: top-level key NAMES only — `gen_ai.tool.call.arguments`
670
- // exists in the spec but is opt-in and carries raw values; we
671
- // deliberately never emit it (PII / prompt-injection echo).
1480
+ // Args: top-level key NAMES by default. The values ride
1481
+ // `gen_ai.tool.call.arguments` — Opt-In in the spec, because they are
1482
+ // raw (PII / prompt-injection echo) — ONLY under `captureToolContent`,
1483
+ // SNAPSHOTTED here (serialized now — the tool receives this same
1484
+ // object and may write into it) and exported at `tool_end`, once the
1485
+ // rules have named the keys they changed (`changedArgKeys`).
672
1486
  const argKeys = p.args !== undefined && typeof p.args === 'object' ? Object.keys(p.args) : [];
673
- const span = pushSpan(t, genAiNames ? `execute_tool ${toolName}` : `tool:${toolName}`, {
1487
+ const nameInSpan = spanNamePart(toolName);
1488
+ const span = pushSpan(t, genAiNames ? `execute_tool ${nameInSpan}` : `tool:${nameInSpan}`, {
674
1489
  'tool.name': toolName,
675
1490
  'gen_ai.operation.name': 'execute_tool',
676
1491
  'gen_ai.tool.name': toolName,
677
1492
  ...(p.toolCallId !== undefined && { 'gen_ai.tool.call.id': p.toolCallId }),
678
1493
  ...(p.protocol !== undefined && { 'agentfootprint.tool.protocol': p.protocol }),
679
1494
  ...(argKeys.length > 0 && { 'agentfootprint.tool.args.keys': boundList(argKeys) }),
1495
+ // Conditionally Required on execute_tool spans "if available" (spec).
1496
+ ...conversationAttr(t),
680
1497
  });
681
- if (p.toolCallId !== undefined)
1498
+ if (p.toolCallId !== undefined) {
682
1499
  t.toolSpans.set(p.toolCallId, span);
1500
+ t.toolNames.set(p.toolCallId, toolName);
1501
+ if (captureToolContent)
1502
+ t.proposedArgs.set(p.toolCallId, snapshotArgs(toolName, p.args));
1503
+ }
683
1504
  break;
684
1505
  }
685
1506
  case 'agentfootprint.stream.tool_end': {
@@ -693,6 +1514,13 @@ export function otelObservability(opts) {
693
1514
  if (p.notDispatched !== undefined)
694
1515
  break;
695
1516
  const errored = p.error !== undefined && p.error !== false;
1517
+ const ranName = p.toolCallId !== undefined ? t.toolNames.get(p.toolCallId) : undefined;
1518
+ if (p.toolCallId !== undefined)
1519
+ t.toolNames.delete(p.toolCallId);
1520
+ // A call that returned without error and was not refused ran a tool
1521
+ // this agent holds — what a `tryInsteadTool` name is recognized by.
1522
+ if (ranName !== undefined && !errored && p.notExecuted !== true)
1523
+ remember(ranTools, ranName);
696
1524
  // Correlate by toolCallId (the only identity ToolEndPayload
697
1525
  // carries) — parallel tool calls end out of LIFO order, so name
698
1526
  // matching alone would close the wrong span. Fallback chain
@@ -708,16 +1536,20 @@ export function otelObservability(opts) {
708
1536
  }
709
1537
  else {
710
1538
  span = popSpan(t, p.toolName !== undefined
711
- ? (name) => name === `tool:${p.toolName}` || name === `execute_tool ${p.toolName}`
1539
+ ? (name) => name === `tool:${spanNamePart(p.toolName ?? '')}` ||
1540
+ name === `execute_tool ${spanNamePart(p.toolName ?? '')}`
712
1541
  : (name) => name.startsWith('tool:') || name.startsWith('execute_tool '));
713
1542
  }
714
1543
  if (!span)
715
1544
  break;
716
- // Result: TYPE only — never the value (PII discipline; mirrors
717
- // the #9 contract and `gen_ai.tool.call.result` stays unemitted).
1545
+ // Result: TYPE by default — never the value (PII discipline; mirrors
1546
+ // the #9 contract). Content only under `captureToolContent`
1547
+ // (`toolContent`): the proposal with rule-changed keys withheld, and
1548
+ // what the model read.
718
1549
  setAttrs(span, {
719
1550
  'agentfootprint.tool.result.type': p.result === null ? 'null' : typeof p.result,
720
1551
  ...(errored && { 'error.type': '_OTHER' }), // boolean error flag — no class info
1552
+ ...(captureToolContent && toolContent(t, p, errored)),
721
1553
  });
722
1554
  endSpan(span, { error: errored });
723
1555
  break;
@@ -736,6 +1568,7 @@ export function otelObservability(opts) {
736
1568
  ...(p.scope !== undefined && { 'agentfootprint.error.scope': bound(p.scope) }),
737
1569
  });
738
1570
  }
1571
+ flushStandings(t);
739
1572
  while (t.stack.length > 1) {
740
1573
  const span = popSpan(t);
741
1574
  if (span)
@@ -745,6 +1578,26 @@ export function otelObservability(opts) {
745
1578
  if (root)
746
1579
  endSpan(root, { error: true });
747
1580
  activeTurns.delete(runId);
1581
+ // The run door's `finally` still files its checker accounting.
1582
+ rememberClosed(runId, t);
1583
+ break;
1584
+ }
1585
+ // A code runner ran: its NAME marks its calls' arguments (the program)
1586
+ // as never exported. Names only; the event carries no code.
1587
+ case 'agentfootprint.tools.code_run': {
1588
+ const tool = event.payload.tool;
1589
+ if (typeof tool === 'string')
1590
+ remember(codeRunners, tool);
1591
+ break;
1592
+ }
1593
+ // Late-capable events: they may name a turn that has already closed.
1594
+ case 'agentfootprint.eval.score': {
1595
+ handleEvalScore(event, runId);
1596
+ break;
1597
+ }
1598
+ case 'agentfootprint.integrity.disposition': {
1599
+ if (explainability)
1600
+ handleDisposition(event, runId);
748
1601
  break;
749
1602
  }
750
1603
  // Other events — annotate / record on the topmost active span.
@@ -772,7 +1625,7 @@ export function otelObservability(opts) {
772
1625
  }
773
1626
  }
774
1627
  }
775
- return {
1628
+ const strategy = {
776
1629
  name: 'otel',
777
1630
  capabilities: { events: true, traces: true },
778
1631
  exportEvent: handleEvent,
@@ -786,6 +1639,7 @@ export function otelObservability(opts) {
786
1639
  stopped = true;
787
1640
  // Defensive: end any spans the agent loop didn't close.
788
1641
  for (const [, t] of activeTurns) {
1642
+ flushStandings(t);
789
1643
  while (t.stack.length > 0) {
790
1644
  const span = popSpan(t);
791
1645
  if (span)
@@ -794,6 +1648,8 @@ export function otelObservability(opts) {
794
1648
  t.toolSpans.clear();
795
1649
  }
796
1650
  activeTurns.clear();
1651
+ closedTurns.clear();
1652
+ forgottenRuns.clear();
797
1653
  },
798
1654
  /**
799
1655
  * Where errors go. Overriding it works — assign `_onError`, or pass
@@ -851,5 +1707,6 @@ export function otelObservability(opts) {
851
1707
  };
852
1708
  },
853
1709
  };
1710
+ return strategy;
854
1711
  }
855
1712
  //# sourceMappingURL=otel.js.map