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
106 changes: 106 additions & 0 deletions apps/ingest/src/ai_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
//! `maple_ai.usage.*` buckets, whatever convention its emitter reported under
//! — see `ai_session/facts.rs` and `ai_session/usage.rs`.
//!
//! One vendor's evaluations are left unstamped entirely: a Mastra scorer run
//! grades a finished agent run and is not a conversation (see
//! [`run_predicates`]).
//!
//! Detection is ordered first-match over the vendor predicates below; the
//! session ID is the first non-empty session-granularity attribute for the
//! matched vendor. A vendor with no session-level key of its own (its
Expand Down Expand Up @@ -426,6 +430,9 @@ struct SpanEvidence<'a> {
langsmith: bool,
llamaindex: bool,
mastra: bool,
/// A Mastra scorer's span: its `scorer_run`/`scorer_step`, or any span it
/// ran (an LLM judge's agent and model calls) - see [`run_predicates`].
mastra_scorer: bool,
agno: bool,
agent_framework: bool,
executor: bool,
Expand Down Expand Up @@ -656,6 +663,13 @@ fn absorb_key<'a>(ev: &mut SpanEvidence<'a>, attr: &'a KeyValue, b0: u8) {
b'm' => {
if key.starts_with("mastra.") {
ev.mastra = true;
// A scorer stamps the run it grades as metadata, and Mastra
// copies a span's metadata onto every span beneath it.
if key == "mastra.metadata.targetTraceId"
|| (key == "mastra.span.type" && value_str(attr).starts_with("scorer_"))
{
ev.mastra_scorer = true;
}
} else if key.starts_with("message.") {
ev.message = true;
} else if key == "model_request_parameters" {
Expand Down Expand Up @@ -1029,6 +1043,15 @@ fn run_predicates(
ev: &SpanEvidence,
span_attrs: &[KeyValue],
) -> Option<AiClassification> {
// A Mastra scorer grades a finished run in a trace of its own, with no
// conversation id: stamped, every scorer run became a `trace:` session, its
// `scorer_*` spans counted as tool calls and an LLM judge as a second agent.
// It is an evaluation, not a conversation, so none of it is agent work -
// whichever instrumentation recorded the span (a judge's model call can sit
// under LangSmith's scope, which the vendor order would name first).
if ev.mastra_scorer {
return None;
}
let ctx = Ctx {
scope,
resource,
Expand Down Expand Up @@ -2132,6 +2155,89 @@ mod tests {
);
}

#[test]
fn mastra_scorer_runs_are_not_agent_work() {
// capture `blind-ts-mastra` (EU, 2026-09-29): a live scorer grades the
// agent's run in a root trace of its own. Every span of it carries the
// graded run as `mastra.metadata.target*`, including the LLM judge's
// agent and model call beneath a `scorer_step`.
const SCOPE: &str = "@mastra/otel-exporter";
const TARGET: (&str, &str) = (
"mastra.metadata.targetTraceId",
"7df8c67d7b9310062270dae3ddc6e010",
);
let spans: &[(&str, &[(&str, &str)])] = &[
(
"scorer_run code-tool-call-accuracy-scorer",
&[
("gen_ai.operation.name", "scorer_run"),
("mastra.span.type", "scorer_run"),
TARGET,
],
),
(
"scorer_step translation-quality-scorer",
&[
("gen_ai.operation.name", "scorer_step"),
("mastra.span.type", "scorer_step"),
TARGET,
],
),
(
"invoke_agent judge",
&[
("gen_ai.operation.name", "invoke_agent"),
("gen_ai.agent.name", "judge"),
("mastra.span.type", "agent_run"),
TARGET,
],
),
(
"chat openai/gpt-5-mini",
&[
("gen_ai.operation.name", "chat"),
("gen_ai.request.model", "openai/gpt-5-mini"),
("gen_ai.usage.input_tokens", "352"),
("mastra.span.type", "model_inference"),
TARGET,
],
),
// A scorer run with no graded trace still names its own type.
(
"scorer_run code-tool-call-accuracy-scorer",
&[("mastra.span.type", "scorer_run")],
),
];
for (name, span) in spans {
assert!(
classify(SCOPE, name, span, &[]).is_none(),
"{name} was stamped"
);
}
// A judge's model call recorded by another instrumentation still
// carries the graded run, and is not stamped under that vendor either.
assert!(classify(
"langsmith",
"chat openai/gpt-5-mini",
&[("gen_ai.operation.name", "chat"), TARGET],
&[],
)
.is_none());
// The run it graded is still the agent's.
classified(
SCOPE,
"invoke_agent translator",
&[
("gen_ai.operation.name", "invoke_agent"),
("gen_ai.conversation.id", "maple-demo-conversation-1"),
("mastra.span.type", "agent_run"),
],
&[],
"mastra",
Some("maple-demo-conversation-1"),
);
}

#[test]
fn effect_guarded_span_names_require_the_effect_sdk_resource() {
classified(
Expand Down
31 changes: 24 additions & 7 deletions apps/ingest/src/ai_session/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,12 +392,15 @@ fn agent_name(facts: &Facts, vendor: &str, span_name: &str) -> Option<String> {
}

/// A tool call: the convention's tool operation, or, under an operation the
/// convention does not name, a tool name or a span name saying "tool".
/// convention does not name, a tool name. A span name saying "tool" counts
/// only when no operation is named: one that names its own (LangSmith's
/// `chain` over LangGraph's `tools` node, a Mastra `scorer_step`) has said
/// what it is.
fn is_tool_call(facts: &Facts, span_name: &str) -> bool {
let op = facts.operation();
op == "execute_tool"
|| (!usage::KNOWN_OPS.contains(&op)
&& (facts.has_tool_name() || name_has(span_name, "tool")))
&& (facts.has_tool_name() || (op.is_empty() && name_has(span_name, "tool"))))
}

/// Mark a stamped tool call as failed after the fact: Claude Code records a
Expand Down Expand Up @@ -812,14 +815,28 @@ mod tests {
assert_eq!(agno[0], pairs(&[("maple_ai.llm_call", "0")]));
}

/// Outside the convention's operations a tool name or a span name saying
/// "tool" makes a tool call; a memory operation never does.
/// Outside the convention's operations a tool name makes a tool call, and
/// a span name saying "tool" does only when no operation is named (the
/// Mastra scorer and LangSmith `chain` wrappers name one); a memory
/// operation never does.
#[test]
fn tool_calls_by_name_under_unknown_operations() {
let got = stamps(
"support-agent",
vec![
span("run_tools", &[("gen_ai.operation.name", "workflow_step")]),
span("run_tools", &[("traceloop.span.kind", "task")]),
span(
"mcp_tool_call search",
&[
("gen_ai.operation.name", "mcp_tool_call"),
("gen_ai.tool.name", "search"),
],
),
span(
"scorer_step code-tool-call-accuracy-scorer",
&[("gen_ai.operation.name", "scorer_step")],
),
span("tools", &[("gen_ai.operation.name", "chain")]),
span(
"search_memory notes",
&[
Expand All @@ -829,8 +846,8 @@ mod tests {
),
],
);
assert!(has(&got[0], TOOL_CALL_ATTR));
assert!(!has(&got[1], TOOL_CALL_ATTR));
let tool_calls: Vec<bool> = got.iter().map(|span| has(span, TOOL_CALL_ATTR)).collect();
assert_eq!(tool_calls, [true, true, false, false, false]);
}

/// A value the warehouse Map holds as a string counts whatever its OTLP
Expand Down
20 changes: 17 additions & 3 deletions apps/ingest/src/ai_session/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ fn named_like_a_model_call(op: &str, span_name: &str, facts: &Facts) -> bool {
if KNOWN_OPS.contains(&op) {
return false;
}
if facts.has_tool_name() || name_has(span_name, "tool") {
if facts.has_tool_name() || (op.is_empty() && name_has(span_name, "tool")) {
return false;
}
if name_has(span_name, "agent") || name_has(span_name, "workflow") {
Expand Down Expand Up @@ -1368,7 +1368,8 @@ mod tests {

/// `captures/jev_agents`: `decide` is jev's own model call, found by the
/// unknown-dialect fallback; an unknown workflow op is not a call, nor is a
/// memory operation naming its embedding model.
/// memory operation naming its embedding model. A "tool" in the name of a
/// span that names its own operation does not rule it out.
#[test]
fn unknown_dialect_calls_by_name_and_model() {
let stamped = stamp_spans(
Expand Down Expand Up @@ -1411,6 +1412,15 @@ mod tests {
("gen_ai.request.model", "text-embedding-3-small"),
],
),
(
"rank_tools openai/gpt-4o-mini",
&[
("gen_ai.operation.name", "rank"),
("gen_ai.request.model", "openai/gpt-4o-mini"),
("gen_ai.usage.input_tokens", "52"),
("gen_ai.usage.output_tokens", "9"),
],
),
],
);
assert!(stamped.iter().all(|span| span.vendor == "unknown:genai"));
Expand All @@ -1422,7 +1432,11 @@ mod tests {
.iter()
.map(|span| span.llm_call.as_deref())
.collect();
assert_eq!(calls, [Some("1"), Some("1"), Some("0"), Some("0")]);
assert_eq!(
calls,
[Some("1"), Some("1"), Some("0"), Some("0"), Some("1")]
);
assert_eq!(stamped[4].buckets, Some([52, 0, 0, 9, 0]));
}

// --- Guards and the write itself ------------------------------------
Expand Down
37 changes: 37 additions & 0 deletions packages/agent-sessions/src/session-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,43 @@ describe("classifyAiSpan", () => {
expect(named("chat gpt-5")).toBe("inference")
})

it("does not read 'tool' off the name of a span that names an unknown operation", () => {
// `blind-ts-mastra` (EU, 2026-09-29): the Mastra exporter lowercases a span
// type the convention has no name for into `gen_ai.operation.name`; a
// LangSmith OTel `chain` wraps LangGraph's `tools` node.
const unknownOp = (spanName: string, operationName: string) =>
makeSpan({
spanId: "a",
startMs: 0,
durationMs: 1,
spanName,
vendorId: "mastra",
genAi: { operationName },
})

for (const span of [
unknownOp("scorer_run code-tool-call-accuracy-scorer", "scorer_run"),
unknownOp("scorer_step code-tool-call-accuracy-scorer", "scorer_step"),
unknownOp("tools", "chain"),
unknownOp("HumanInTheLoopMiddleware.wrap_tool_call", "chain"),
]) {
expect(classifyAiSpan(span)).toBe("agent")
expect(isLlmCall(span)).toBe(false)
}
// A tool name is still a tool call, whatever the operation says.
expect(
classifyAiSpan(
makeSpan({
spanId: "a",
startMs: 0,
durationMs: 1,
spanName: "mcp_tool_call search",
genAi: { operationName: "mcp_tool_call", toolName: "search" },
}),
),
).toBe("tool")
})

it("classifies a span with no AI signal as other, whatever it is called", () => {
const httpSpan = makeSpan({
spanId: "a",
Expand Down
9 changes: 7 additions & 2 deletions packages/agent-sessions/src/session-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,14 @@ export function classifyAiSpan(span: AiSessionSpan): AiSpanCategory {

// `gen_ai.operation.name` is optional and plenty of instrumentations skip it.
// The span name is the next best evidence: by convention it leads with the
// operation ("execute_tool read_file", "chat gpt-5").
// operation ("execute_tool read_file", "chat gpt-5"). Except for "tool": a
// span naming an operation the convention does not know (LangSmith's
// `chain`, a Mastra `scorer_step`) has said what it is, and the LangGraph
// `tools` node or a `code-tool-call-accuracy-scorer` is not a tool call.
const name = span.spanName.toLowerCase()
if (span.genAi.toolName !== undefined || name.includes("tool")) return "tool"
if (span.genAi.toolName !== undefined || (operation === undefined && name.includes("tool"))) {
return "tool"
}
if (name.includes("agent") || name.includes("workflow")) return "agent"
if (spanModel(span) !== undefined || name.includes("chat") || name.includes("completion")) {
return "inference"
Expand Down
Loading
Loading