Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 8 additions & 18 deletions apps/ai/src/mcp/tools/__tests__/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@

const breakdownPairRows = [{ model: "claude-sonnet-4", service: "api", calls: 9 }]

// Exactly what the read returns for a payload it cut: `AI_TOOL_ERROR_PAYLOAD_MAX`
// characters, with the row reporting the true size the span carried.
const LONG_ARGUMENTS = `{"query":"${"x".repeat(AI_TOOL_ERROR_PAYLOAD_MAX - 12)}"}`
// Arguments longer than the read keeps: it cuts them to `AI_TOOL_ERROR_PAYLOAD_MAX`
// characters and reports the true size the span carried.
const LONG_ARGUMENTS_BYTES = 12_000
const LONG_ARGUMENTS = `{"query":"${"x".repeat(LONG_ARGUMENTS_BYTES - 12)}"}`

/** The payload rows below are joined to these by trace and span id. */
const occurrence = (ids: [string, string], sessionId: string, timestamp: string, durationNs: number) => ({
Expand All @@ -153,20 +153,14 @@
]

/** `statusCode` is Title case on the wire, like every other Maple span status. */
const payload = (ids: [string, string], args: string, argumentsBytes: number) => ({
const payload = (ids: [string, string], args: string, result = "TimeoutError: upstream timed out") => ({
traceId: ids[0].repeat(32),
spanId: ids[1].repeat(16),
statusCode: "Error",
arguments: args,
argumentsBytes,
result: "TimeoutError: upstream timed out",
resultBytes: 32,
spanAttributes: { "gen_ai.tool.call.arguments": args, "gen_ai.tool.call.result": result },
})

const payloadRows = [
payload(["a", "b"], LONG_ARGUMENTS, LONG_ARGUMENTS_BYTES),
payload(["c", "d"], `{"query":"short"}`, 17),
]
const payloadRows = [payload(["a", "b"], LONG_ARGUMENTS), payload(["c", "d"], `{"query":"short"}`)]

/** The payload read, answering with rows of the test's own shape. */
const payloadsAre = (rows: ReadonlyArray<unknown>): FixtureRule[] => [
Expand Down Expand Up @@ -424,7 +418,7 @@

it("renders sessions, variants, the breakdown and clipped sample payloads", async () => {
const rendered = await render(ERROR_DETAIL, { ...GROUP, payload_chars: 100 })
expect(rendered).toContain(`## Tool failure group ${FINGERPRINT}`)

Check failure on line 421 in apps/ai/src/mcp/tools/__tests__/agent-tools.test.ts

View workflow job for this annotation

GitHub Actions / Tests (small-2)

src/mcp/tools/__tests__/agent-tools.test.ts > get_agent_tool_error rendering > renders sessions, variants, the breakdown and clipped sample payloads

AssertionError: expected 'Query failed: Compiled query row 0 di…' to contain '## Tool failure group 104532821939483…' - Expected + Received - ## Tool failure group 10453282193948324021 + Query failed: Compiled query row 0 did not match its declared output schema + If this looks like a bug in Maple rather than in your call, offer the user to report it with `send_maple_feedback`. ❯ src/mcp/tools/__tests__/agent-tools.test.ts:421:20
expect(rowCells(rendered, "sess_a")).toEqual([
"sess_a",
"openai-agents",
Expand Down Expand Up @@ -453,7 +447,7 @@
// One row past the page is what tells the read there is a next page — on
// the samples statement, not the variants read's constant `LIMIT 20`.
expect(samplesSql().some((sql) => /\bLIMIT 2\b/.test(sql))).toBe(true)
expect(rendered).toContain("More samples exist past this page")

Check failure on line 450 in apps/ai/src/mcp/tools/__tests__/agent-tools.test.ts

View workflow job for this annotation

GitHub Actions / Tests (small-2)

src/mcp/tools/__tests__/agent-tools.test.ts > get_agent_tool_error rendering > clamps samples_limit and reports that more samples exist

AssertionError: expected 'Query failed: Compiled query row 0 di…' to contain 'More samples exist past this page' - Expected + Received - More samples exist past this page + Query failed: Compiled query row 0 did not match its declared output schema + If this looks like a bug in Maple rather than in your call, offer the user to report it with `send_maple_feedback`. ❯ src/mcp/tools/__tests__/agent-tools.test.ts:450:20

executedSql.length = 0
await call(ERROR_DETAIL, { ...GROUP, samples_limit: 999 })
Expand All @@ -462,7 +456,7 @@

it("clamps payload_chars to its default and its ceiling", async () => {
// The clip appends one ellipsis to the characters it kept.
expect(clippedArguments(await render(ERROR_DETAIL, GROUP))).toHaveLength(801)

Check failure on line 459 in apps/ai/src/mcp/tools/__tests__/agent-tools.test.ts

View workflow job for this annotation

GitHub Actions / Tests (small-2)

src/mcp/tools/__tests__/agent-tools.test.ts > get_agent_tool_error rendering > clamps payload_chars to its default and its ceiling

AssertionError: expected '' to have a length of 801 but got +0 - Expected + Received - 801 + 0 ❯ src/mcp/tools/__tests__/agent-tools.test.ts:459:63
// The ceiling is the read's own cap, and the payload the read already cut
// still renders with the ellipsis its byte total implies.
expect(
Expand All @@ -478,22 +472,18 @@
})

it("tells a payload the span left empty from one it never carried", async () => {
const rows = payloadRows.map((row) => ({ ...row, arguments: "", argumentsBytes: 0 }))
const rows = [payload(["a", "b"], ""), payload(["c", "d"], "")]
const rendered = await renderWith(payloadsAre(rows), ERROR_DETAIL, GROUP)
expect(rendered).toContain("(empty)")

Check failure on line 477 in apps/ai/src/mcp/tools/__tests__/agent-tools.test.ts

View workflow job for this annotation

GitHub Actions / Tests (small-2)

src/mcp/tools/__tests__/agent-tools.test.ts > get_agent_tool_error rendering > tells a payload the span left empty from one it never carried

AssertionError: expected 'Query failed: Compiled query row 0 di…' to contain '(empty)' - Expected + Received - (empty) + Query failed: Compiled query row 0 did not match its declared output schema + If this looks like a bug in Maple rather than in your call, offer the user to report it with `send_maple_feedback`. ❯ src/mcp/tools/__tests__/agent-tools.test.ts:477:20
expect(rendered).not.toContain("(not available: the span was not retained)")
})

it("fences a sample payload that carries a code fence of its own", async () => {
const rows = payloadRows.map((row) => ({
...row,
result: FENCED_RESULT,
resultBytes: FENCED_RESULT.length,
}))
const rows = [payload(["a", "b"], "{}", FENCED_RESULT), payload(["c", "d"], "{}", FENCED_RESULT)]
const rendered = await renderWith(payloadsAre(rows), ERROR_DETAIL, GROUP)
// One backtick longer than the longest run inside the payload, which is
// left intact.
expect(rendered).toContain(`\`\`\`\`\n${FENCED_RESULT}`)

Check failure on line 486 in apps/ai/src/mcp/tools/__tests__/agent-tools.test.ts

View workflow job for this annotation

GitHub Actions / Tests (small-2)

src/mcp/tools/__tests__/agent-tools.test.ts > get_agent_tool_error rendering > fences a sample payload that carries a code fence of its own

AssertionError: expected 'Query failed: Compiled query row 0 di…' to contain '````\nTimeoutError in:\n```js\nawait …' - Expected + Received - ```` - TimeoutError in: - ```js - await search() - ``` + Query failed: Compiled query row 0 did not match its declared output schema + If this looks like a bug in Maple rather than in your call, offer the user to report it with `send_maple_feedback`. ❯ src/mcp/tools/__tests__/agent-tools.test.ts:486:20
expect(rendered).toContain("```js")
})

Expand Down
11 changes: 6 additions & 5 deletions apps/api/src/routes/internal/ai-sessions.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1668,10 +1668,11 @@
traceId: occurrence.traceId,
spanId: occurrence.spanId,
statusCode: "Ok",
arguments: "{}",
argumentsBytes: 2,
result: occurrence.message,
resultBytes: 58,
spanAttributes: {
"maple_ai.vendor.id": "maple",
"gen_ai.tool.call.arguments": "{}",
"gen_ai.tool.call.result": occurrence.message,
},
},
],
)
Expand All @@ -1685,7 +1686,7 @@
tool: "run_tests",
fingerprint: FINGERPRINT,
})
expect(response.status).toBe(200)

Check failure on line 1689 in apps/api/src/routes/internal/ai-sessions.http.test.ts

View workflow job for this annotation

GitHub Actions / Tests (small-1)

src/routes/internal/ai-sessions.http.test.ts > POST /internal/ai-sessions/tools/error-samples > reads the payloads for the page, over its own extent

AssertionError: expected 500 to be 200 // Object.is equality - Expected + Received - 200 + 500 ❯ src/routes/internal/ai-sessions.http.test.ts:1689:28
expect(contexts).toEqual(["aiToolsErrorOccurrences", "aiToolsErrorPayloads"])
const [occurrencesSql, payloadSql] = seen
// One row past the default page, to know whether there is another.
Expand All @@ -1708,7 +1709,7 @@
arguments: "{}",
argumentsBytes: 2,
result: occurrence.message,
resultBytes: 58,
resultBytes: occurrence.message.length,
},
],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,11 @@ export const readAiToolErrorSamples = Effect.fn("aiSessions.toolErrorSamples")(f
),
{ profile: "list", context: "aiToolsErrorPayloads" },
)
const payloadBySpan = new Map(payloads.map((row) => [`${row.traceId}:${row.spanId}`, row] as const))
const payloadBySpan = new Map(
payloads.map(
(row) => [`${row.traceId}:${row.spanId}`, Integrations.aiToolErrorPayload(row)] as const,
),
)
return new AiToolErrorSamplesResponse({
...(nextCursor !== undefined && { nextCursor }),
occurrences: occurrences.map((row) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ const TRACE_IDS_AT_1 = missingKey('["evidence"][1]["traceIds"]')
const LOG_PATTERNS_AT_0 = missingKey('["evidence"][0]["logPatterns"]')
/** A failure that says why in its status message alone. */
const REFUSED = "sandbox refused the command"
/** `flaky_tool`'s parameter schema. */
const FLAKY_SCHEMA =
'{"properties": {"retries": {"type": "integer"}}, "required": ["retries"], "type": "object"}'

interface SeedSpan {
readonly traceId: string
Expand Down Expand Up @@ -280,10 +283,17 @@ const SEED_SPANS: ReadonlyArray<SeedSpan> = [
durationNs: 8_000_000,
status: "Error",
statusMessage: "upstream returned 503",
// An OpenInference tool span whose GenAI dual-write copied the parameter
// schema into the arguments slot: the payload read shows `input.value`,
// as the session page does.
attrs: agentSpan({
[MAPLE_AI_VENDOR_ID_ATTR]: "openai_agents_sdk",
"openinference.span.kind": "TOOL",
"gen_ai.operation.name": "execute_tool",
"gen_ai.tool.name": "flaky_tool",
"gen_ai.tool.call.arguments": '{"retries":1}',
"tool.parameters": FLAKY_SCHEMA,
"gen_ai.tool.call.arguments": FLAKY_SCHEMA,
"input.value": '{"retries":1}',
"gen_ai.tool.call.result": '{"error":"503"}',
...aiGatewayStamps({
toolCall: true,
Expand Down Expand Up @@ -825,13 +835,15 @@ describe.skipIf(!clickhouseE2eEnabled)("agent tools reads", () => {
)
assert.isFalse(payloads.sql.includes(flakyWindow.startTime))
assert.deepStrictEqual(
Effect.runSync(payloads.decodeRows(await runJson(payloads.sql))).map((row) => ({
spanId: row.spanId,
statusCode: row.statusCode,
arguments: row.arguments,
argumentsBytes: row.argumentsBytes,
resultBytes: row.resultBytes,
})),
Effect.runSync(payloads.decodeRows(await runJson(payloads.sql)))
.map(Integrations.aiToolErrorPayload)
.map((row) => ({
spanId: row.spanId,
statusCode: row.statusCode,
arguments: row.arguments,
argumentsBytes: row.argumentsBytes,
resultBytes: row.resultBytes,
})),
[
{
spanId: "tools-flaky-1",
Expand Down Expand Up @@ -870,7 +882,9 @@ describe.skipIf(!clickhouseE2eEnabled)("agent tools reads", () => {
{ orgId: ORG_ID, ...slice },
{ rowSchema: Integrations.aiToolErrorPayloadsRowSchema },
)
const rows = Effect.runSync(payloads.decodeRows(await runJson(payloads.sql)))
const rows = Effect.runSync(payloads.decodeRows(await runJson(payloads.sql))).map(
Integrations.aiToolErrorPayload,
)
assert.deepStrictEqual(
[...rows]
.sort((a, b) => a.spanId.localeCompare(b.spanId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1170,10 +1170,8 @@ SELECT
TraceId AS traceId,
SpanId AS spanId,
StatusCode AS statusCode,
leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.arguments'], ''), nullIf(SpanAttributes['ai.toolCall.args'], ''), ''), 4000) AS arguments,
length(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.arguments'], ''), nullIf(SpanAttributes['ai.toolCall.args'], ''), '')) AS argumentsBytes,
leftUTF8(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), nullIf(SpanAttributes['ai.toolCall.result'], ''), ''), 4000) AS result,
length(coalesce(nullIf(SpanAttributes['gen_ai.tool.call.result'], ''), nullIf(SpanAttributes['ai.toolCall.result'], ''), '')) AS resultBytes
mapApply((k, v) -> (k, leftUTF8(v, 16384)), mapFilter((k, v) -> (((k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'maple_ai.agent.name', 'gen_ai.operation.name', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model', 'gen_ai.request.max_tokens', 'gen_ai.request.choice.count', 'gen_ai.request.temperature', 'gen_ai.request.top_p', 'gen_ai.request.top_k', 'gen_ai.request.stop_sequences', 'gen_ai.request.frequency_penalty', 'gen_ai.request.presence_penalty', 'gen_ai.request.encoding_formats', 'gen_ai.request.seed', 'gen_ai.openai.request.seed', 'gen_ai.request.stream', 'gen_ai.request.reasoning.level', 'gen_ai.request.previous_response.id', 'gen_ai.request.stream_cursor', 'gen_ai.response.id', 'gen_ai.response.model', 'gen_ai.response.finish_reasons', 'gen_ai.response.finish_reason', 'gen_ai.response.status', 'gen_ai.response.time_to_first_chunk', 'gen_ai.output.type', 'gen_ai.usage.input_tokens', 'gen_ai.usage.prompt_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.input_tokens.cached', 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_write.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.completion_tokens', 'gen_ai.usage.reasoning.output_tokens', 'gen_ai.usage.output_tokens.reasoning', 'gen_ai.usage.cost', 'gen_ai.usage.total_cost', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.cost', 'gen_ai.conversation.id', 'gen_ai.conversation.compacted', 'gen_ai.agent.id', 'gen_ai.agent.name', 'gen_ai.agent.description', 'gen_ai.agent.version', 'gen_ai.tool.name', 'gen_ai.tool.call.id', 'gen_ai.tool.description', 'gen_ai.tool.type', 'gen_ai.tool.call.arguments', 'gen_ai.tool.call.result', 'gen_ai.tool.definitions', 'gen_ai.system_instructions', 'gen_ai.input.messages', 'gen_ai.prompt', 'gen_ai.output.messages', 'gen_ai.completion', 'gen_ai.data_source.id', 'gen_ai.retrieval.query.text', 'gen_ai.retrieval.top_k', 'gen_ai.retrieval.documents', 'gen_ai.memory.store.id', 'gen_ai.memory.record.id', 'gen_ai.memory.record.count', 'gen_ai.memory.query.text', 'gen_ai.memory.records', 'gen_ai.embeddings.dimension.count', 'gen_ai.evaluation.name', 'gen_ai.evaluation.score.value', 'gen_ai.evaluation.score.label', 'gen_ai.evaluation.explanation', 'gen_ai.prompt.name', 'gen_ai.prompt.version', 'gen_ai.workflow.name', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_name', 'error.type', 'server.address', 'server.port', 'ai.model.provider', 'ai.model.id', 'ai.response.id', 'ai.response.model', 'ai.response.finishReason', 'gen_ai.client.operation.time_to_first_chunk', 'ai.usage.inputTokens', 'ai.usage.promptTokens', 'ai.usage.cachedInputTokens', 'ai.usage.inputTokenDetails.cacheReadTokens', 'ai.usage.inputTokenDetails.cacheWriteTokens', 'ai.usage.outputTokens', 'ai.usage.completionTokens', 'ai.usage.reasoningTokens', 'ai.usage.outputTokenDetails.reasoningTokens', 'ai.telemetry.functionId', 'ai.toolCall.name', 'ai.toolCall.id', 'ai.toolCall.args', 'ai.toolCall.result', 'ai.prompt.tools', 'ai.prompt.messages', 'ai.prompt', 'llm.provider', 'llm.system', 'llm.model_name', 'llm.finish_reason', 'llm.token_count.prompt', 'llm.token_count.prompt_details.cache_read', 'llm.token_count.completion', 'llm.token_count.completion_details.reasoning', 'llm.cost.total', 'tool.name', 'tool.description', 'llm.tools', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes)) AS spanAttributes,
mapApply((k, v) -> (k, length(v)), mapFilter((k, v) -> lengthUTF8(v) > 16384, mapFilter((k, v) -> (((k IN ('maple_ai.session.id', 'maple_ai.vendor.id', 'maple_ai.vendor.version', 'maple_ai.agent.name', 'gen_ai.operation.name', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model', 'gen_ai.request.max_tokens', 'gen_ai.request.choice.count', 'gen_ai.request.temperature', 'gen_ai.request.top_p', 'gen_ai.request.top_k', 'gen_ai.request.stop_sequences', 'gen_ai.request.frequency_penalty', 'gen_ai.request.presence_penalty', 'gen_ai.request.encoding_formats', 'gen_ai.request.seed', 'gen_ai.openai.request.seed', 'gen_ai.request.stream', 'gen_ai.request.reasoning.level', 'gen_ai.request.previous_response.id', 'gen_ai.request.stream_cursor', 'gen_ai.response.id', 'gen_ai.response.model', 'gen_ai.response.finish_reasons', 'gen_ai.response.finish_reason', 'gen_ai.response.status', 'gen_ai.response.time_to_first_chunk', 'gen_ai.output.type', 'gen_ai.usage.input_tokens', 'gen_ai.usage.prompt_tokens', 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.input_tokens.cached', 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_write.input_tokens', 'gen_ai.usage.output_tokens', 'gen_ai.usage.completion_tokens', 'gen_ai.usage.reasoning.output_tokens', 'gen_ai.usage.output_tokens.reasoning', 'gen_ai.usage.cost', 'gen_ai.usage.total_cost', 'maple_ai.llm_call', 'maple_ai.tool_call', 'maple_ai.error', 'maple_ai.usage.input_tokens', 'maple_ai.usage.cache_read_tokens', 'maple_ai.usage.cache_write_tokens', 'maple_ai.usage.output_tokens', 'maple_ai.usage.reasoning_tokens', 'maple_ai.usage.cost', 'gen_ai.conversation.id', 'gen_ai.conversation.compacted', 'gen_ai.agent.id', 'gen_ai.agent.name', 'gen_ai.agent.description', 'gen_ai.agent.version', 'gen_ai.tool.name', 'gen_ai.tool.call.id', 'gen_ai.tool.description', 'gen_ai.tool.type', 'gen_ai.tool.call.arguments', 'gen_ai.tool.call.result', 'gen_ai.tool.definitions', 'gen_ai.system_instructions', 'gen_ai.input.messages', 'gen_ai.prompt', 'gen_ai.output.messages', 'gen_ai.completion', 'gen_ai.data_source.id', 'gen_ai.retrieval.query.text', 'gen_ai.retrieval.top_k', 'gen_ai.retrieval.documents', 'gen_ai.memory.store.id', 'gen_ai.memory.record.id', 'gen_ai.memory.record.count', 'gen_ai.memory.query.text', 'gen_ai.memory.records', 'gen_ai.embeddings.dimension.count', 'gen_ai.evaluation.name', 'gen_ai.evaluation.score.value', 'gen_ai.evaluation.score.label', 'gen_ai.evaluation.explanation', 'gen_ai.prompt.name', 'gen_ai.prompt.version', 'gen_ai.workflow.name', 'span.metadata.attempt_index', 'span.metadata.status_code', 'trace.metadata.openrouter.provider_name', 'error.type', 'server.address', 'server.port', 'ai.model.provider', 'ai.model.id', 'ai.response.id', 'ai.response.model', 'ai.response.finishReason', 'gen_ai.client.operation.time_to_first_chunk', 'ai.usage.inputTokens', 'ai.usage.promptTokens', 'ai.usage.cachedInputTokens', 'ai.usage.inputTokenDetails.cacheReadTokens', 'ai.usage.inputTokenDetails.cacheWriteTokens', 'ai.usage.outputTokens', 'ai.usage.completionTokens', 'ai.usage.reasoningTokens', 'ai.usage.outputTokenDetails.reasoningTokens', 'ai.telemetry.functionId', 'ai.toolCall.name', 'ai.toolCall.id', 'ai.toolCall.args', 'ai.toolCall.result', 'ai.prompt.tools', 'ai.prompt.messages', 'ai.prompt', 'llm.provider', 'llm.system', 'llm.model_name', 'llm.finish_reason', 'llm.token_count.prompt', 'llm.token_count.prompt_details.cache_read', 'llm.token_count.completion', 'llm.token_count.completion_details.reasoning', 'llm.cost.total', 'tool.name', 'tool.description', 'llm.tools', 'openinference.span.kind', 'tool.parameters', 'input.value', 'output.value', 'eve.turn.id', 'maple_ai.turn.id') OR k LIKE 'gen_ai.prompt.variable.%') OR k LIKE 'llm.input_messages.%') OR k LIKE 'llm.output_messages.%'), SpanAttributes))) AS cutAttributeBytes
FROM trace_detail_spans
WHERE OrgId = 'org_sql_catalog'
AND Timestamp >= '2026-01-02 11:15:00.000000000'
Expand Down
45 changes: 34 additions & 11 deletions packages/query-engine-integrations/src/ai/ai-integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import { AI_VENDOR_INTEGRATIONS } from "./ai-vendors"
import { isRecord, unwrapMessages, unwrapOutputMessages, unwrapToolMessage } from "./ai-messages"

export interface AiRefineContext {
readonly row: AiSessionSpansOutput
/** The span's own attributes — the map the source key lists read. */
readonly attributes: Record<string, string>
/** One attribute decoded the way the mapper decodes `field`; `undefined`
Expand Down Expand Up @@ -348,16 +347,13 @@ export const AI_NON_SIGNAL_FIELDS: ReadonlySet<AiGenAiField> = new Set([...AI_CO
const hasAiSignal = (values: MutableAiGenAiValues): boolean =>
Object.keys(values).some((field) => !AI_NON_SIGNAL_FIELDS.has(field as AiGenAiField))

export const mapAiSpan = (row: AiSessionSpansOutput): AiAgentSpan => {
// Span attributes only, envelope and source keys alike. The gateway strips
// `maple_ai.*` from span attributes before stamping its own verdict, so a
// span-level value is authoritative — and it does not touch resource
// attributes, where one forged `gen_ai.*` or `maple_ai.*` key would mark
// every span in the service as an AI span.
const attributes = row.spanAttributes
const vendorId = readAttribute(attributes, MAPLE_AI_VENDOR_ID_ATTR)
/** Every catalog field of one span, through the integration its vendor stamp
* selects: the source keys in order, then the refine hooks. */
const decodeGenAi = (
attributes: Record<string, string>,
vendorId: string | undefined,
): MutableAiGenAiValues => {
const integration = resolveAiIntegration(vendorId)

// SAFETY: the catalog correlates each field with its value type, but a loop
// over the field union cannot carry that correlation. `decodeAttribute` is
// driven by the same catalog entry as the field it is written under, so the
Expand All @@ -377,11 +373,38 @@ export const mapAiSpan = (row: AiSessionSpansOutput): AiAgentSpan => {
break
}
}
integration.refine?.(genAi, { row, attributes, read })
integration.refine?.(genAi, { attributes, read })
// The agent the ingest gateway named, which the list and its facets show: it
// reads names no dialect key carries (OpenAI Agents' graph node).
const stampedAgent = readAttribute(attributes, MAPLE_AI_STAMP_ATTRS.agentName)
if (stampedAgent !== undefined) genAi.agentName = stampedAgent
return genAi
}

/**
* What a tool call was called with and what came back, decoded exactly as the
* session page decodes the span — so a view that reads one tool span on its
* own shows the payload the transcript shows: an OpenInference span's real
* `input.value` rather than the parameter schema its GenAI dual-write copied
* into `gen_ai.tool.call.arguments`, a LangChain `ToolMessage` unwrapped to
* its content. `undefined` where the span captured none.
*/
export const aiToolCallPayload = (
attributes: Record<string, string>,
): { readonly arguments: unknown; readonly result: unknown } => {
const genAi = decodeGenAi(attributes, readAttribute(attributes, MAPLE_AI_VENDOR_ID_ATTR))
return { arguments: genAi.toolCallArguments, result: genAi.toolCallResult }
}

export const mapAiSpan = (row: AiSessionSpansOutput): AiAgentSpan => {
// Span attributes only, envelope and source keys alike. The gateway strips
// `maple_ai.*` from span attributes before stamping its own verdict, so a
// span-level value is authoritative — and it does not touch resource
// attributes, where one forged `gen_ai.*` or `maple_ai.*` key would mark
// every span in the service as an AI span.
const attributes = row.spanAttributes
const vendorId = readAttribute(attributes, MAPLE_AI_VENDOR_ID_ATTR)
const genAi = decodeGenAi(attributes, vendorId)

const promptVariables = collectPromptVariables(attributes)
const sessionId = readAttribute(attributes, MAPLE_AI_SESSION_ID_ATTR)
Expand Down
Loading
Loading