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.
- package/AGENTS.md +1 -1
- package/CAPABILITIES.md +3 -0
- package/ai-instructions/claude-code/SKILL.md +1 -1
- package/dist/adapters/hosting/agentCoreA2A.js +5 -0
- package/dist/adapters/hosting/agentCoreA2A.js.map +1 -1
- package/dist/adapters/hosting/agentcore.js +5 -0
- package/dist/adapters/hosting/agentcore.js.map +1 -1
- package/dist/adapters/hosting/foundryResponses.js +5 -0
- package/dist/adapters/hosting/foundryResponses.js.map +1 -1
- package/dist/adapters/observability/agentcore.js +18 -5
- package/dist/adapters/observability/agentcore.js.map +1 -1
- package/dist/adapters/observability/audit.js +17 -4
- package/dist/adapters/observability/audit.js.map +1 -1
- package/dist/adapters/observability/otel.js +890 -33
- package/dist/adapters/observability/otel.js.map +1 -1
- package/dist/artifacts/capability.js +9 -3
- package/dist/artifacts/capability.js.map +1 -1
- package/dist/bridge/eventMeta.js +40 -1
- package/dist/bridge/eventMeta.js.map +1 -1
- package/dist/core/Agent.js +40 -6
- package/dist/core/Agent.js.map +1 -1
- package/dist/core/RunnerBase.js +32 -1
- package/dist/core/RunnerBase.js.map +1 -1
- package/dist/core/agent/AgentBuilder.js +13 -1
- package/dist/core/agent/AgentBuilder.js.map +1 -1
- package/dist/core/agent/stages/toolCalls.js +64 -7
- package/dist/core/agent/stages/toolCalls.js.map +1 -1
- package/dist/core/flowchartAsTool.js +8 -2
- package/dist/core/flowchartAsTool.js.map +1 -1
- package/dist/core/runbook/runbookAsTool.js +7 -2
- package/dist/core/runbook/runbookAsTool.js.map +1 -1
- package/dist/core/toolShownArgs.js +55 -0
- package/dist/core/toolShownArgs.js.map +1 -0
- package/dist/esm/adapters/hosting/agentCoreA2A.d.ts +14 -1
- package/dist/esm/adapters/hosting/agentCoreA2A.js +5 -0
- package/dist/esm/adapters/hosting/agentCoreA2A.js.map +1 -1
- package/dist/esm/adapters/hosting/agentcore.d.ts +18 -2
- package/dist/esm/adapters/hosting/agentcore.js +5 -0
- package/dist/esm/adapters/hosting/agentcore.js.map +1 -1
- package/dist/esm/adapters/hosting/foundryResponses.d.ts +15 -1
- package/dist/esm/adapters/hosting/foundryResponses.js +5 -0
- package/dist/esm/adapters/hosting/foundryResponses.js.map +1 -1
- package/dist/esm/adapters/observability/agentcore.d.ts +17 -5
- package/dist/esm/adapters/observability/agentcore.js +18 -5
- package/dist/esm/adapters/observability/agentcore.js.map +1 -1
- package/dist/esm/adapters/observability/audit.d.ts +6 -2
- package/dist/esm/adapters/observability/audit.js +17 -4
- package/dist/esm/adapters/observability/audit.js.map +1 -1
- package/dist/esm/adapters/observability/otel.d.ts +240 -11
- package/dist/esm/adapters/observability/otel.js +890 -33
- package/dist/esm/adapters/observability/otel.js.map +1 -1
- package/dist/esm/artifacts/capability.d.ts +3 -1
- package/dist/esm/artifacts/capability.js +9 -3
- package/dist/esm/artifacts/capability.js.map +1 -1
- package/dist/esm/bridge/eventMeta.d.ts +32 -0
- package/dist/esm/bridge/eventMeta.js +38 -0
- package/dist/esm/bridge/eventMeta.js.map +1 -1
- package/dist/esm/core/Agent.d.ts +18 -5
- package/dist/esm/core/Agent.js +42 -8
- package/dist/esm/core/Agent.js.map +1 -1
- package/dist/esm/core/RunnerBase.d.ts +20 -0
- package/dist/esm/core/RunnerBase.js +32 -1
- package/dist/esm/core/RunnerBase.js.map +1 -1
- package/dist/esm/core/agent/AgentBuilder.js +13 -1
- package/dist/esm/core/agent/AgentBuilder.js.map +1 -1
- package/dist/esm/core/agent/stages/toolCalls.d.ts +11 -0
- package/dist/esm/core/agent/stages/toolCalls.js +64 -7
- package/dist/esm/core/agent/stages/toolCalls.js.map +1 -1
- package/dist/esm/core/flowchartAsTool.js +8 -2
- package/dist/esm/core/flowchartAsTool.js.map +1 -1
- package/dist/esm/core/runbook/runbookAsTool.js +7 -2
- package/dist/esm/core/runbook/runbookAsTool.js.map +1 -1
- package/dist/esm/core/toolShownArgs.d.ts +62 -0
- package/dist/esm/core/toolShownArgs.js +49 -0
- package/dist/esm/core/toolShownArgs.js.map +1 -0
- package/dist/esm/events/payloads.d.ts +122 -6
- package/dist/esm/events/registry.d.ts +3 -1
- package/dist/esm/events/registry.js +2 -0
- package/dist/esm/events/registry.js.map +1 -1
- package/dist/esm/events/types.d.ts +7 -0
- package/dist/esm/hosting/artifactWire.d.ts +10 -2
- package/dist/esm/hosting/artifactWire.js +20 -2
- package/dist/esm/hosting/artifactWire.js.map +1 -1
- package/dist/esm/hosting/doorGuard.d.ts +280 -0
- package/dist/esm/hosting/doorGuard.js +499 -0
- package/dist/esm/hosting/doorGuard.js.map +1 -0
- package/dist/esm/hosting/errors.d.ts +113 -0
- package/dist/esm/hosting/errors.js +177 -0
- package/dist/esm/hosting/errors.js.map +1 -1
- package/dist/esm/hosting/httpHost.d.ts +32 -3
- package/dist/esm/hosting/httpHost.js +218 -4
- package/dist/esm/hosting/httpHost.js.map +1 -1
- package/dist/esm/hosting/index.d.ts +7 -5
- package/dist/esm/hosting/index.js +10 -3
- package/dist/esm/hosting/index.js.map +1 -1
- package/dist/esm/hosting/ingressRecord.d.ts +64 -10
- package/dist/esm/hosting/ingressRecord.js +65 -6
- package/dist/esm/hosting/ingressRecord.js.map +1 -1
- package/dist/esm/hosting/nodeHost.d.ts +32 -2
- package/dist/esm/hosting/nodeHost.js +10 -0
- package/dist/esm/hosting/nodeHost.js.map +1 -1
- package/dist/esm/hosting/standingAgent.js +362 -51
- package/dist/esm/hosting/standingAgent.js.map +1 -1
- package/dist/esm/hosting/turnArtifacts.d.ts +93 -0
- package/dist/esm/hosting/turnArtifacts.js +126 -0
- package/dist/esm/hosting/turnArtifacts.js.map +1 -0
- package/dist/esm/hosting/types.d.ts +196 -3
- package/dist/esm/hosting/types.js +10 -0
- package/dist/esm/hosting/types.js.map +1 -1
- package/dist/esm/hosting/webSocketConversation.d.ts +15 -1
- package/dist/esm/hosting/webSocketConversation.js +54 -2
- package/dist/esm/hosting/webSocketConversation.js.map +1 -1
- package/dist/esm/integrity/disposition/ledger.d.ts +8 -0
- package/dist/esm/integrity/disposition/ledger.js +0 -0
- package/dist/esm/integrity/disposition/ledger.js.map +1 -1
- package/dist/esm/lib/mcp/mcpServe.js +39 -2
- package/dist/esm/lib/mcp/mcpServe.js.map +1 -1
- package/dist/esm/lib/mcp/types.d.ts +30 -0
- package/dist/esm/recorders/observability/commentary/artifactPhrases.d.ts +4 -0
- package/dist/esm/recorders/observability/commentary/artifactPhrases.js +9 -0
- package/dist/esm/recorders/observability/commentary/artifactPhrases.js.map +1 -1
- package/dist/esm/recorders/observability/commentary/commentaryTemplates.js +30 -3
- package/dist/esm/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
- package/dist/esm/recorders/observability/recordRun.d.ts +12 -0
- package/dist/esm/recorders/observability/recordRun.js +17 -1
- package/dist/esm/recorders/observability/recordRun.js.map +1 -1
- package/dist/esm/strategies/attach.js +87 -1
- package/dist/esm/strategies/attach.js.map +1 -1
- package/dist/events/registry.js +2 -0
- package/dist/events/registry.js.map +1 -1
- package/dist/hosting/artifactWire.js +21 -3
- package/dist/hosting/artifactWire.js.map +1 -1
- package/dist/hosting/doorGuard.js +509 -0
- package/dist/hosting/doorGuard.js.map +1 -0
- package/dist/hosting/errors.js +183 -1
- package/dist/hosting/errors.js.map +1 -1
- package/dist/hosting/httpHost.js +218 -4
- package/dist/hosting/httpHost.js.map +1 -1
- package/dist/hosting/index.js +19 -2
- package/dist/hosting/index.js.map +1 -1
- package/dist/hosting/ingressRecord.js +66 -6
- package/dist/hosting/ingressRecord.js.map +1 -1
- package/dist/hosting/nodeHost.js +11 -1
- package/dist/hosting/nodeHost.js.map +1 -1
- package/dist/hosting/standingAgent.js +359 -48
- package/dist/hosting/standingAgent.js.map +1 -1
- package/dist/hosting/turnArtifacts.js +130 -0
- package/dist/hosting/turnArtifacts.js.map +1 -0
- package/dist/hosting/types.js +11 -1
- package/dist/hosting/types.js.map +1 -1
- package/dist/hosting/webSocketConversation.js +54 -2
- package/dist/hosting/webSocketConversation.js.map +1 -1
- package/dist/integrity/disposition/ledger.js +0 -0
- package/dist/integrity/disposition/ledger.js.map +1 -1
- package/dist/lib/mcp/mcpServe.js +39 -2
- package/dist/lib/mcp/mcpServe.js.map +1 -1
- package/dist/recorders/observability/commentary/artifactPhrases.js +10 -1
- package/dist/recorders/observability/commentary/artifactPhrases.js.map +1 -1
- package/dist/recorders/observability/commentary/commentaryTemplates.js +29 -2
- package/dist/recorders/observability/commentary/commentaryTemplates.js.map +1 -1
- package/dist/recorders/observability/recordRun.js +20 -3
- package/dist/recorders/observability/recordRun.js.map +1 -1
- package/dist/strategies/attach.js +87 -1
- package/dist/strategies/attach.js.map +1 -1
- package/dist/types/adapters/hosting/agentCoreA2A.d.ts +14 -1
- package/dist/types/adapters/hosting/agentCoreA2A.d.ts.map +1 -1
- package/dist/types/adapters/hosting/agentcore.d.ts +18 -2
- package/dist/types/adapters/hosting/agentcore.d.ts.map +1 -1
- package/dist/types/adapters/hosting/foundryResponses.d.ts +15 -1
- package/dist/types/adapters/hosting/foundryResponses.d.ts.map +1 -1
- package/dist/types/adapters/observability/agentcore.d.ts +17 -5
- package/dist/types/adapters/observability/agentcore.d.ts.map +1 -1
- package/dist/types/adapters/observability/audit.d.ts +6 -2
- package/dist/types/adapters/observability/audit.d.ts.map +1 -1
- package/dist/types/adapters/observability/otel.d.ts +240 -11
- package/dist/types/adapters/observability/otel.d.ts.map +1 -1
- package/dist/types/artifacts/capability.d.ts +3 -1
- package/dist/types/artifacts/capability.d.ts.map +1 -1
- package/dist/types/bridge/eventMeta.d.ts +32 -0
- package/dist/types/bridge/eventMeta.d.ts.map +1 -1
- package/dist/types/core/Agent.d.ts +18 -5
- package/dist/types/core/Agent.d.ts.map +1 -1
- package/dist/types/core/RunnerBase.d.ts +20 -0
- package/dist/types/core/RunnerBase.d.ts.map +1 -1
- package/dist/types/core/agent/AgentBuilder.d.ts.map +1 -1
- package/dist/types/core/agent/stages/toolCalls.d.ts +11 -0
- package/dist/types/core/agent/stages/toolCalls.d.ts.map +1 -1
- package/dist/types/core/flowchartAsTool.d.ts.map +1 -1
- package/dist/types/core/runbook/runbookAsTool.d.ts.map +1 -1
- package/dist/types/core/toolShownArgs.d.ts +63 -0
- package/dist/types/core/toolShownArgs.d.ts.map +1 -0
- package/dist/types/events/payloads.d.ts +122 -6
- package/dist/types/events/payloads.d.ts.map +1 -1
- package/dist/types/events/registry.d.ts +3 -1
- package/dist/types/events/registry.d.ts.map +1 -1
- package/dist/types/events/types.d.ts +7 -0
- package/dist/types/events/types.d.ts.map +1 -1
- package/dist/types/hosting/artifactWire.d.ts +10 -2
- package/dist/types/hosting/artifactWire.d.ts.map +1 -1
- package/dist/types/hosting/doorGuard.d.ts +281 -0
- package/dist/types/hosting/doorGuard.d.ts.map +1 -0
- package/dist/types/hosting/errors.d.ts +113 -0
- package/dist/types/hosting/errors.d.ts.map +1 -1
- package/dist/types/hosting/httpHost.d.ts +32 -3
- package/dist/types/hosting/httpHost.d.ts.map +1 -1
- package/dist/types/hosting/index.d.ts +7 -5
- package/dist/types/hosting/index.d.ts.map +1 -1
- package/dist/types/hosting/ingressRecord.d.ts +64 -10
- package/dist/types/hosting/ingressRecord.d.ts.map +1 -1
- package/dist/types/hosting/nodeHost.d.ts +32 -2
- package/dist/types/hosting/nodeHost.d.ts.map +1 -1
- package/dist/types/hosting/standingAgent.d.ts.map +1 -1
- package/dist/types/hosting/turnArtifacts.d.ts +94 -0
- package/dist/types/hosting/turnArtifacts.d.ts.map +1 -0
- package/dist/types/hosting/types.d.ts +196 -3
- package/dist/types/hosting/types.d.ts.map +1 -1
- package/dist/types/hosting/webSocketConversation.d.ts +15 -1
- package/dist/types/hosting/webSocketConversation.d.ts.map +1 -1
- package/dist/types/integrity/disposition/ledger.d.ts +8 -0
- package/dist/types/integrity/disposition/ledger.d.ts.map +1 -1
- package/dist/types/lib/mcp/mcpServe.d.ts.map +1 -1
- package/dist/types/lib/mcp/types.d.ts +30 -0
- package/dist/types/lib/mcp/types.d.ts.map +1 -1
- package/dist/types/recorders/observability/commentary/artifactPhrases.d.ts +4 -0
- package/dist/types/recorders/observability/commentary/artifactPhrases.d.ts.map +1 -1
- package/dist/types/recorders/observability/commentary/commentaryTemplates.d.ts.map +1 -1
- package/dist/types/recorders/observability/recordRun.d.ts +12 -0
- package/dist/types/recorders/observability/recordRun.d.ts.map +1 -1
- package/dist/types/strategies/attach.d.ts.map +1 -1
- 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
|
|
67
|
-
* above
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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,
|
|
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(
|
|
818
|
+
for (const [key, value] of Object.entries(capped))
|
|
320
819
|
flattened[`${name}.${key}`] = value;
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
490
|
-
//
|
|
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
|
-
'
|
|
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' &&
|
|
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
|
|
670
|
-
//
|
|
671
|
-
//
|
|
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
|
|
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}` ||
|
|
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
|
|
717
|
-
// the #9 contract
|
|
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
|
-
|
|
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
|