From bcd4b363bfeeb24b724132d8eb6382678aa84f5a Mon Sep 17 00:00:00 2001 From: krisztianfekete Date: Fri, 13 Nov 2026 15:59:12 +0100 Subject: [PATCH 1/6] fix(genai): score a paused and resumed turn as one turn --- docs/eval-set-format.md | 2 + docs/otel-compatibility.md | 2 + src/agentevals/genai/extract.py | 82 +++++++++++++++++- src/agentevals/genai/grouping.py | 48 ++++++++++- tests/genai/test_resumed_turns.py | 138 ++++++++++++++++++++++++++++++ tests/otel/builders.py | 4 + 6 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 tests/genai/test_resumed_turns.py diff --git a/docs/eval-set-format.md b/docs/eval-set-format.md index 9a32f71..9cf6380 100644 --- a/docs/eval-set-format.md +++ b/docs/eval-set-format.md @@ -293,6 +293,8 @@ Turns are compared in order. Extra or missing turns are reported as `unexpected_ Use `conversation` when each turn of a multi turn conversation is its own trace. +In every mode, a trace that resumes a paused turn of another trace (a span link between their `invoke_agent` spans, see [Sending telemetry](otel-compatibility.md#what-agentevals-needs)) is evaluated with that trace. + ## Usage ### CLI diff --git a/docs/otel-compatibility.md b/docs/otel-compatibility.md index c833461..894f9f9 100644 --- a/docs/otel-compatibility.md +++ b/docs/otel-compatibility.md @@ -32,6 +32,8 @@ Working setups for each are in [examples/zero-code-examples](../examples/zero-co If a framework only instruments the model client (each model call is its own trace), agentevals joins a call that answers a tool result to the turn before it. This needs message content. +If a runtime pauses a turn (for an approval or a question) and runs the rest as a new `invoke_agent` span, often in a new trace, add a span link from the new span to the `invoke_agent` span that started the turn. agentevals then scores both parts as one turn: the user input of the first, the final answer of the last, and the tools of both. The new span must have the same `gen_ai.agent.name` and no input messages of its own. Linked traces are evaluated together whatever the grouping. + ## Sessions Live traces are grouped into sessions by the first key present: diff --git a/src/agentevals/genai/extract.py b/src/agentevals/genai/extract.py index 3a3dad3..3674519 100644 --- a/src/agentevals/genai/extract.py +++ b/src/agentevals/genai/extract.py @@ -25,6 +25,11 @@ puts each model call of a tool round in its own trace). When both sides carry call ids, the response must answer one of the requested calls. This reads message content; with content capture off the turns stay split and the later one carries a warning. +* An agent anchored turn whose anchor records no input of its own and links to the anchor of an + earlier turn of the same agent resumes that turn (a runtime that pauses a turn for an approval or + a question runs the rest of it as a new agent span, often in a new trace). The link is structural, + so this needs no message content. A tool call recorded in both parts counts once, preferring + the span that finished without error. Every walk is iterative, so adversarial nesting depth cannot exhaust the stack. """ @@ -40,6 +45,7 @@ STATUS_OK, LogRecord, Span, + SpanRef, Trace, attr_float, attr_int, @@ -713,10 +719,42 @@ def _maybe_continues(previous: Turn, turn: Turn) -> bool: ) +def resume_links(view: SpanView) -> list[SpanRef]: + """The spans an agent span may resume: its links, when it records no input of its own.""" + if classify(view) != AGENT or view.messages(sc.INPUT_MESSAGES): + return [] + return [SpanRef(link.trace_id, link.span_id) for link in view.span.links] + + +def resumes(view: SpanView, origin: SpanView) -> bool: + """Whether ``view``, linked to ``origin``, continues the turn ``origin`` started.""" + return ( + classify(origin) == AGENT + and origin.span.start_time_unix_nano < view.span.start_time_unix_nano + and attr_str(origin.attrs, sc.AGENT_NAME) == attr_str(view.attrs, sc.AGENT_NAME) + ) + + +def _one_per_call_id(tools: list[ToolCall]) -> list[ToolCall]: + out: list[ToolCall] = [] + seen: dict[str, int] = {} + for tool in tools: + index = seen.get(tool.call_id) if tool.call_id else None + if index is None: + if tool.call_id: + seen[tool.call_id] = len(out) + out.append(tool) + elif out[index].is_error and not tool.is_error: + out[index] = tool + return out + + def _merge(previous: Turn, turn: Turn) -> Turn: calls = sorted([*previous.llm_calls, *turn.llm_calls], key=lambda c: (c.start_ns, c.end_ns)) warnings = [*previous.warnings, *turn.warnings] - span_tools = [t.model_copy() for t in (*previous.tool_calls, *turn.tool_calls) if t.ref is not None] + span_tools = _one_per_call_id( + [t.model_copy() for t in (*previous.tool_calls, *turn.tool_calls) if t.ref is not None] + ) if span_tools: tools = _fill_from_parts(span_tools, calls, warnings) else: @@ -755,11 +793,47 @@ def _merge(previous: Turn, turn: Turn) -> Turn: ) -def _join_continuations(turns: list[Turn]) -> list[Turn]: +def _anchor_views(traces: Sequence[Trace], turns: Iterable[Turn]) -> dict[SpanRef, SpanView]: + """Views of the agent anchors that carry links, and of every span those links name.""" + by_id = {t.trace_id: t for t in traces} + + def view(ref: SpanRef) -> SpanView | None: + trace = by_id.get(ref.trace_id) + span = trace.spans.get(ref.span_id) if trace else None + return overlay(span, trace.logs_of(span)) if span else None + + views: dict[SpanRef, SpanView] = {} + for turn in turns: + if turn.anchor_kind != "agent" or (anchor := view(turn.ref)) is None or not anchor.span.links: + continue + views[turn.ref] = anchor + for ref in resume_links(anchor): + if ref not in views and (target := view(ref)) is not None: + views[ref] = target + return views + + +def _join_continuations(turns: list[Turn], views: dict[SpanRef, SpanView]) -> list[Turn]: out: list[Turn] = [] + owner: dict[SpanRef, int] = {} for turn in turns: + anchor = views.get(turn.ref) + links = resume_links(anchor) if anchor else [] + origin = next((owner[r] for r in links if r in owner and resumes(anchor, views[r])), None) + if origin is not None: + out[origin] = _merge(out[origin], turn) + owner[turn.ref] = origin + continue + if links: + warning = Gap( + code=GAP_POSSIBLE_CONTINUATION, + reason=f"turn {turn.ref.span_id} has no input and links to no earlier turn of the same agent " + "in this evaluation; if it resumes a paused turn, evaluate both traces together", + ) + turn = turn.model_copy(update={"warnings": [*turn.warnings, warning]}) if out and _continues(out[-1], turn): out[-1] = _merge(out[-1], turn) + owner[turn.ref] = len(out) - 1 continue if out and _maybe_continues(out[-1], turn): warning = Gap( @@ -768,6 +842,7 @@ def _join_continuations(turns: list[Turn]) -> list[Turn]: "but message content was not captured; kept as its own turn", ) turn = turn.model_copy(update={"warnings": [*turn.warnings, warning]}) + owner[turn.ref] = len(out) out.append(turn) return out @@ -786,7 +861,8 @@ def conversation_from_turns( traces = list(traces) turns = [t for per_trace in turns_per_trace for t in per_trace] turns.sort(key=lambda t: (t.start_ns, t.ref.span_id)) - turns = [t.model_copy(update={"index": i}) for i, t in enumerate(_join_continuations(turns))] + joined = _join_continuations(turns, _anchor_views(traces, turns)) + turns = [t.model_copy(update={"index": i}) for i, t in enumerate(joined)] conversation_id = next((t.conversation_id for t in turns if t.conversation_id), None) starts: dict[str, int] = {} for trace in traces: diff --git a/src/agentevals/genai/grouping.py b/src/agentevals/genai/grouping.py index 5554053..169ae50 100644 --- a/src/agentevals/genai/grouping.py +++ b/src/agentevals/genai/grouping.py @@ -8,6 +8,9 @@ 4. the trace id Values are caller chosen, so they are coerced to text and capped before use. + +In every mode, a trace whose agent span resumes a turn started in another trace of the batch (see +:func:`.extract.resumes`) joins that trace's group, so a paused and resumed turn is evaluated whole. """ from __future__ import annotations @@ -16,7 +19,9 @@ from typing import Any, Literal from ..otel.identity import SESSION_NAME, coerce_key -from ..otel.model import Span, Trace +from ..otel.model import Span, SpanRef, Trace +from .extract import resume_links, resumes +from .overlay import SpanView, overlay from .semconv import CONVERSATION_ID, SESSION_ID KeyKind = Literal["name", "conversation", "session_id", "trace"] @@ -59,12 +64,47 @@ def has_session_name(traces: Iterable[Trace]) -> bool: return any(conversation_key(t)[0] == "name" for t in traces) +def _resume_edges(traces: list[Trace]) -> Iterable[tuple[str, str]]: + """``(resumed trace id, origin trace id)`` for every resume link between two traces of the batch.""" + by_id = {t.trace_id: t for t in traces} + origins: dict[SpanRef, SpanView | None] = {} + for trace in traces: + for span in trace.spans.values(): + if not span.links: + continue + view = overlay(span, trace.logs_of(span)) + for ref in dict.fromkeys(resume_links(view)): + if ref.trace_id == trace.trace_id: + continue + if ref not in origins: + origin_trace = by_id.get(ref.trace_id) + origin = origin_trace.spans.get(ref.span_id) if origin_trace else None + origins[ref] = overlay(origin, origin_trace.logs_of(origin)) if origin else None + if origins[ref] is not None and resumes(view, origins[ref]): + yield trace.trace_id, ref.trace_id + + def group_traces(traces: Iterable[Trace], by_conversation: bool) -> list[tuple[str, list[Trace]]]: - """Evaluation groups in first-seen order. Without conversation grouping each trace stands alone.""" + """Evaluation groups in first-seen order. Without conversation grouping each trace stands alone, + unless it resumes a turn of another trace.""" + traces = list(traces) + key_of = {t.trace_id: conversation_key(t)[1] if by_conversation else t.trace_id for t in traces} + parent: dict[str, str] = {} + + def root(key: str) -> str: + while key in parent: + parent[key] = parent.get(parent[key], parent[key]) + key = parent[key] + return key + + for resumed, origin in _resume_edges(traces): + a, b = root(key_of[resumed]), root(key_of[origin]) + if a != b: + parent[a] = b + groups: dict[str, list[Trace]] = {} for trace in traces: - key = conversation_key(trace)[1] if by_conversation else trace.trace_id - groups.setdefault(key, []).append(trace) + groups.setdefault(root(key_of[trace.trace_id]), []).append(trace) for members in groups.values(): members.sort(key=lambda t: t.start_time_unix_nano) return list(groups.items()) diff --git a/tests/genai/test_resumed_turns.py b/tests/genai/test_resumed_turns.py new file mode 100644 index 0000000..c526351 --- /dev/null +++ b/tests/genai/test_resumed_turns.py @@ -0,0 +1,138 @@ +"""A turn that pauses for an approval or a question and resumes as a new agent span is one turn.""" + +from __future__ import annotations + +import pytest + +from agentevals.genai.extract import extract_conversation +from agentevals.genai.grouping import group_traces +from agentevals.genai.messages import text_of +from agentevals.genai.model import GAP_POSSIBLE_CONTINUATION +from agentevals.otel.decode import decode_json_document +from agentevals.otel.model import build_traces +from agentevals.runner import evaluation_groups +from otel.builders import assistant, chat, request, sid, span, tid, user + +DELETE = ("call_1", "delete_pod", {"name": "web-1"}) + + +def _traces(*spans: dict): + decoded = decode_json_document(request(list(spans)), strict=True) + traces, _ = build_traces(decoded.spans, decoded.logs) + return traces + + +def _agent(trace, span_id, start, end, inputs=None, outputs=None, links=(), name="assistant"): + attrs = {"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": name, "gen_ai.conversation.id": "ctx-1"} + if inputs: + attrs["gen_ai.input.messages"] = inputs + if outputs: + attrs["gen_ai.output.messages"] = outputs + return span(trace, span_id, None, f"invoke_agent {name}", attrs, start, end, links=links) + + +def _tool(trace, span_id, parent, start, end, error=None): + attrs = {"gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": "delete_pod", "gen_ai.tool.call.id": "call_1"} + if error: + attrs["error.type"] = error + return span(trace, span_id, parent, "execute_tool delete_pod", attrs, start, end) + + +def _initial(trace="a", span_id="a1", start=1_000, question="Delete pod web-1", name="assistant"): + return [ + _agent(trace, span_id, start, start + 2_000, inputs=[user(question)], name=name), + chat( + trace=trace, + span_id=f"{span_id}c", + parent=span_id, + start=start + 100, + end=start + 1_000, + inputs=[user(question)], + outputs=[assistant(tool_calls=[DELETE])], + finish="tool_call", + ), + ] + + +def _resumed(trace="b", span_id="a2", start=60_000, origin=("a", "a1"), answer="Deleted web-1", name="assistant"): + return [ + _agent(trace, span_id, start, start + 3_000, outputs=[assistant(answer)], links=[origin], name=name), + _tool(trace, f"{span_id}t", span_id, start + 100, start + 1_000), + chat(trace=trace, span_id=f"{span_id}c", parent=span_id, start=start + 1_100, end=start + 2_000, content=False), + ] + + +def test_a_resumed_segment_joins_the_turn_it_continues(): + conversation = extract_conversation(_traces(*_initial(), *_resumed()), "ctx-1") + [turn] = conversation.turns + assert (turn.ref.trace_id, turn.ref.span_id) == (tid("a"), sid("a1")) + assert text_of(turn.user_input) == "Delete pod web-1" + assert text_of(turn.final_output) == "Deleted web-1" + assert [(t.name, t.ref.span_id) for t in turn.tool_calls] == [("delete_pod", sid("a2t"))] + assert len(turn.llm_calls) == 2 + assert conversation.trace_ids == [tid("a"), tid("b")] + + +def test_a_tool_open_at_the_pause_counts_once_as_the_finished_call(): + initial = [*_initial(), _tool("a", "a1t", "a1", 1_200, 3_000, error="unfinished")] + [turn] = extract_conversation(_traces(*initial, *_resumed()), "ctx-1").turns + assert [(t.ref.span_id, t.is_error) for t in turn.tool_calls] == [(sid("a2t"), False)] + + +def test_a_tool_left_unfinished_without_a_later_finish_stays_as_recorded(): + initial = [*_initial(), _tool("a", "a1t", "a1", 1_200, 3_000, error="unfinished")] + resumed = _resumed() + resumed.pop(1) + [turn] = extract_conversation(_traces(*initial, *resumed), "ctx-1").turns + assert [(t.ref.span_id, t.error_type) for t in turn.tool_calls] == [(sid("a1t"), "unfinished")] + + +def test_every_resumed_segment_joins_the_same_turn(): + second = _resumed(trace="c", span_id="a3", start=90_000, answer="Done, and cleaned up") + [turn] = extract_conversation(_traces(*_initial(), *_resumed(), *second), "ctx-1").turns + assert text_of(turn.final_output) == "Done, and cleaned up" + assert len(turn.llm_calls) == 3 + + +def test_it_joins_the_linked_turn_when_another_turn_comes_between(): + between = _initial(trace="m", span_id="m1", start=30_000, question="List pods") + turns = extract_conversation(_traces(*_initial(), *between, *_resumed()), "ctx-1").turns + assert [(text_of(t.user_input), text_of(t.final_output)) for t in turns] == [ + ("Delete pod web-1", "Deleted web-1"), + ("List pods", None), + ] + + +def test_a_linked_segment_with_its_own_input_is_a_new_turn(): + resumed = _resumed() + resumed[0] = _agent("b", "a2", 60_000, 63_000, inputs=[user("And web-2?")], links=[("a", "a1")]) + assert len(extract_conversation(_traces(*_initial(), *resumed), "ctx-1").turns) == 2 + + +def test_a_link_to_another_agent_is_not_a_resume(): + resumed = _resumed(name="other") + assert len(extract_conversation(_traces(*_initial(), *resumed), "ctx-1").turns) == 2 + + +def test_a_resumed_segment_without_its_origin_keeps_a_warning(): + [turn] = extract_conversation(_traces(*_resumed()), "ctx-1").turns + assert text_of(turn.final_output) == "Deleted web-1" + assert [w.code for w in turn.warnings] == [GAP_POSSIBLE_CONTINUATION] + + +@pytest.mark.parametrize("by_conversation", [False, True]) +def test_linked_traces_form_one_group_in_every_mode(by_conversation): + unrelated = _initial(trace="z", span_id="z1", start=100_000, question="Hi") + groups = group_traces(_traces(*_resumed(), *_initial(), *unrelated), by_conversation) + if by_conversation: + assert [[t.trace_id for t in members] for _, members in groups] == [[tid("a"), tid("b"), tid("z")]] + else: + assert [(key, [t.trace_id for t in members]) for key, members in groups] == [ + (tid("a"), [tid("a"), tid("b")]), + (tid("z"), [tid("z")]), + ] + + +def test_default_grouping_evaluates_the_paused_turn_whole(): + groups = evaluation_groups(_traces(*_initial(), *_resumed()), "auto") + assert [[t.trace_id for t in members] for _, members in groups] == [[tid("a"), tid("b")]] diff --git a/tests/otel/builders.py b/tests/otel/builders.py index 45648ec..5687bba 100644 --- a/tests/otel/builders.py +++ b/tests/otel/builders.py @@ -38,7 +38,9 @@ def span( start: int = 1_000, end: int = 2_000, flags: int | None = None, + links: list[tuple[str, str]] = (), ) -> dict: + """``links`` are ``(trace, span_id)`` labels of the linked spans.""" data = { "traceId": tid(trace), "spanId": sid(span_id), @@ -52,6 +54,8 @@ def span( data["parentSpanId"] = sid(parent) if flags is not None: data["flags"] = flags + if links: + data["links"] = [{"traceId": tid(t), "spanId": sid(s), "attributes": []} for t, s in links] return data From 44288392d5402b102a0790dcb88e87c08f95e9d5 Mon Sep 17 00:00:00 2001 From: krisztianfekete Date: Fri, 13 Nov 2026 15:59:12 +0100 Subject: [PATCH 2/6] feat(api): name the evaluated span in TurnRef --- docs/grpc-api.md | 2 + docs/opentelemetry-pipeline.md | 2 +- .../agentevals/api/v1alpha1/evaluation.proto | 5 ++ src/agentevals/api/rpc/convert.py | 2 +- src/agentevals/api/v1alpha1/evaluation_pb2.py | 46 ++++++------- .../api/v1alpha1/evaluation_pb2.pyi | 8 ++- src/agentevals/evaluation_events.py | 7 +- src/agentevals/runner.py | 19 +++++- tests/otel/test_emit.py | 64 ++++++++++++++++++- .../api/v1alpha1/evaluation_pb.d.ts | 15 +++++ .../agentevals/api/v1alpha1/evaluation_pb.js | 2 +- ui/src/lib/types.ts | 3 + 12 files changed, 139 insertions(+), 36 deletions(-) diff --git a/docs/grpc-api.md b/docs/grpc-api.md index 7b6ae6c..4913105 100644 --- a/docs/grpc-api.md +++ b/docs/grpc-api.md @@ -20,6 +20,8 @@ Sessions and conversations come back as views: turns with their user text, tool Traces go in as OTLP protobuf (`TracesData` and `LogsData`) or as files (OTLP/JSON, OTLP/JSONL or Jaeger JSON), the same formats the REST API takes. +`EvaluateTraces` lists the judged turns of each group in `turn_refs`. `evaluated_trace_id` and `evaluated_span_id` name the span the turn's results belong to, the same span agentevals attaches its own [evaluation result records](opentelemetry-pipeline.md#send-evaluation-results-from-agentevals) to. Use it when you attach results to telemetry yourself. + ## Calling it With reflection on, `grpcurl` can list and call everything: diff --git a/docs/opentelemetry-pipeline.md b/docs/opentelemetry-pipeline.md index 0d0208c..01a57e0 100644 --- a/docs/opentelemetry-pipeline.md +++ b/docs/opentelemetry-pipeline.md @@ -158,7 +158,7 @@ agentevals run traces.json -e eval_set.json -m tool_trajectory_avg_score --emit- `OTEL_EXPORTER_OTLP_PROTOCOL=grpc` (with port 4317) switches to gRPC. `OTEL_SDK_DISABLED=true` or `OTEL_LOGS_EXPORTER=none` turns emitting off even when it is enabled. `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` work as usual. -Each result is a `gen_ai.evaluation.result` log event attached to the span it scores (the turn's agent span, or its last model call). Metrics that score each turn give one event per turn; others give one event on the last turn. +Each result is a `gen_ai.evaluation.result` log event attached to the span it scores (the turn's agent span, or its last model call). Metrics that score each turn give one event per turn; others give one event on the last turn. The evaluation APIs name the same span in each turn reference (`evaluated_trace_id`, `evaluated_span_id`). | Attribute | Value | |---|---| diff --git a/proto/agentevals/api/v1alpha1/evaluation.proto b/proto/agentevals/api/v1alpha1/evaluation.proto index 4d2eece..d614a02 100644 --- a/proto/agentevals/api/v1alpha1/evaluation.proto +++ b/proto/agentevals/api/v1alpha1/evaluation.proto @@ -146,8 +146,13 @@ message MetricResult { } message TurnRef { + // The turn's anchor: its agent or workflow span, else the root span of its trace. string trace_id = 1; string span_id = 2; + // The span the turn's gen_ai.evaluation.result records attach to: the anchor, or the turn's + // final model call when the anchor is not a GenAI span. Attach results here. + string evaluated_trace_id = 3; + string evaluated_span_id = 4; } // The results of one evaluation group: a trace, or a conversation of several traces. diff --git a/src/agentevals/api/rpc/convert.py b/src/agentevals/api/rpc/convert.py index 8a11341..50b694e 100644 --- a/src/agentevals/api/rpc/convert.py +++ b/src/agentevals/api/rpc/convert.py @@ -292,7 +292,7 @@ def trace_result_to_pb(result: TraceResult) -> evaluation_pb2.TraceResult: group_key=result.group_key or "", eval_case_id=result.eval_case_id or "", eval_case_match=result.eval_case_match or "", - turn_refs=[evaluation_pb2.TurnRef(trace_id=r.trace_id, span_id=r.span_id) for r in result.turn_refs], + turn_refs=[evaluation_pb2.TurnRef(**r.model_dump()) for r in result.turn_refs], ) if result.performance_metrics: out.performance_metrics.CopyFrom(to_struct(result.performance_metrics)) diff --git a/src/agentevals/api/v1alpha1/evaluation_pb2.py b/src/agentevals/api/v1alpha1/evaluation_pb2.py index f90d811..358aadd 100644 --- a/src/agentevals/api/v1alpha1/evaluation_pb2.py +++ b/src/agentevals/api/v1alpha1/evaluation_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(agentevals/api/v1alpha1/evaluation.proto\x12\x17\x61gentevals.api.v1alpha1\x1a$agentevals/api/v1alpha1/common.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x14\n\x12ListMetricsRequest\"\x91\x02\n\x06Metric\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n\x08\x63\x61tegory\x18\x02 \x01(\tR\x08\x63\x61tegory\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12*\n\x11requires_eval_set\x18\x04 \x01(\x08R\x0frequiresEvalSet\x12!\n\x0crequires_llm\x18\x05 \x01(\x08R\x0brequiresLlm\x12!\n\x0crequires_gcp\x18\x06 \x01(\x08R\x0brequiresGcp\x12)\n\x10requires_rubrics\x18\x07 \x01(\x08R\x0frequiresRubrics\x12\x18\n\x07working\x18\x08 \x01(\x08R\x07working\"P\n\x13ListMetricsResponse\x12\x39\n\x07metrics\x18\x01 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.MetricR\x07metrics\"\xe2\x02\n\x10\x42uiltinEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1f\n\x0bjudge_model\x18\x03 \x01(\tR\njudgeModel\x12`\n\x15trajectory_match_type\x18\x04 \x01(\x0e\x32,.agentevals.api.v1alpha1.TrajectoryMatchTypeR\x13trajectoryMatchType\x12%\n\x0e\x63redential_ref\x18\x05 \x01(\tR\rcredentialRef\x12$\n\x0ejudge_base_url\x18\x06 \x01(\tR\x0cjudgeBaseUrl\x12\x39\n\x07rubrics\x18\x07 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.RubricR\x07rubricsB\x0c\n\n_threshold\",\n\x06Rubric\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04text\x18\x02 \x01(\tR\x04text\"\xe0\x01\n\rCodeEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x12\n\x04path\x18\x06 \x01(\tR\x04pathB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xf8\x01\n\x0fRemoteEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x16\n\x06source\x18\x06 \x01(\tR\x06source\x12\x10\n\x03ref\x18\x07 \x01(\tR\x03refB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xdc\x01\n\tEvaluator\x12\x45\n\x07\x62uiltin\x18\x01 \x01(\x0b\x32).agentevals.api.v1alpha1.BuiltinEvaluatorH\x00R\x07\x62uiltin\x12<\n\x04\x63ode\x18\x02 \x01(\x0b\x32&.agentevals.api.v1alpha1.CodeEvaluatorH\x00R\x04\x63ode\x12\x42\n\x06remote\x18\x03 \x01(\x0b\x32(.agentevals.api.v1alpha1.RemoteEvaluatorH\x00R\x06remoteB\x06\n\x04kind\"\xb0\x02\n\nEvalParams\x12\x42\n\nevaluators\x18\x01 \x03(\x0b\x32\".agentevals.api.v1alpha1.EvaluatorR\nevaluators\x12\x37\n\x15max_concurrent_traces\x18\x02 \x01(\x05H\x00R\x13maxConcurrentTraces\x88\x01\x01\x12\x35\n\x14max_concurrent_evals\x18\x03 \x01(\x05H\x01R\x12maxConcurrentEvals\x88\x01\x01\x12;\n\x08group_by\x18\x04 \x01(\x0e\x32 .agentevals.api.v1alpha1.GroupByR\x07groupByB\x18\n\x16_max_concurrent_tracesB\x17\n\x15_max_concurrent_evals\"t\n\x10InvocationResult\x12\x19\n\x05score\x18\x01 \x01(\x01H\x00R\x05score\x88\x01\x01\x12;\n\x06status\x18\x02 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\x06statusB\x08\n\x06_score\"\xad\x03\n\x0cMetricResult\x12\x1f\n\x0bmetric_name\x18\x01 \x01(\tR\nmetricName\x12\x19\n\x05score\x18\x02 \x01(\x01H\x00R\x05score\x88\x01\x01\x12\x44\n\x0b\x65val_status\x18\x03 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\nevalStatus\x12P\n\x0eper_invocation\x18\x04 \x03(\x0b\x32).agentevals.api.v1alpha1.InvocationResultR\rperInvocation\x12\x19\n\x05\x65rror\x18\x05 \x01(\tH\x01R\x05\x65rror\x88\x01\x01\x12\"\n\nerror_type\x18\x06 \x01(\tH\x02R\terrorType\x88\x01\x01\x12\x31\n\x07\x64\x65tails\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructR\x07\x64\x65tails\x12$\n\x0b\x64uration_ms\x18\x08 \x01(\x01H\x03R\ndurationMs\x88\x01\x01\x42\x08\n\x06_scoreB\x08\n\x06_errorB\r\n\x0b_error_typeB\x0e\n\x0c_duration_ms\"=\n\x07TurnRef\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\x17\n\x07span_id\x18\x02 \x01(\tR\x06spanId\"\xc8\x04\n\x0bTraceResult\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\'\n\x0fnum_invocations\x18\x02 \x01(\x05R\x0enumInvocations\x12L\n\x0emetric_results\x18\x03 \x03(\x0b\x32%.agentevals.api.v1alpha1.MetricResultR\rmetricResults\x12/\n\x13\x63onversion_warnings\x18\x04 \x03(\tR\x12\x63onversionWarnings\x12H\n\x13performance_metrics\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12!\n\x0cservice_name\x18\x06 \x01(\tR\x0bserviceName\x12\x1d\n\nagent_name\x18\x07 \x01(\tR\tagentName\x12\x1b\n\ttrace_ids\x18\x08 \x03(\tR\x08traceIds\x12\'\n\x0f\x63onversation_id\x18\t \x01(\tR\x0e\x63onversationId\x12\x1b\n\tgroup_key\x18\n \x01(\tR\x08groupKey\x12 \n\x0c\x65val_case_id\x18\x0b \x01(\tR\nevalCaseId\x12&\n\x0f\x65val_case_match\x18\x0c \x01(\tR\revalCaseMatch\x12=\n\tturn_refs\x18\r \x03(\x0b\x32 .agentevals.api.v1alpha1.TurnRefR\x08turnRefs\"\xcf\x01\n\tRunResult\x12I\n\rtrace_results\x18\x01 \x03(\x0b\x32$.agentevals.api.v1alpha1.TraceResultR\x0ctraceResults\x12\x16\n\x06\x65rrors\x18\x02 \x03(\tR\x06\x65rrors\x12H\n\x13performance_metrics\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12\x15\n\x06run_id\x18\x04 \x01(\tR\x05runId\"\xa3\x03\n\x15\x45valuateTracesRequest\x12;\n\x06traces\x18\x01 \x01(\x0b\x32#.agentevals.api.v1alpha1.TraceInputR\x06traces\x12;\n\x0c\x61\x64k_eval_set\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructH\x00R\nadkEvalSet\x12;\n\x06params\x18\x03 \x01(\x0b\x32#.agentevals.api.v1alpha1.EvalParamsR\x06params\x12k\n\x0f\x63redential_refs\x18\x04 \x03(\x0b\x32\x42.agentevals.api.v1alpha1.EvaluateTracesRequest.CredentialRefsEntryR\x0e\x63redentialRefs\x1aZ\n\x13\x43redentialRefsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12-\n\x05value\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x05value:\x02\x38\x01\x42\n\n\x08\x65val_set\".\n\x12\x45valuationProgress\x12\x18\n\x07message\x18\x01 \x01(\tR\x07message\"\xb7\x01\n\x10GroupPerformance\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12H\n\x13performance_metrics\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12>\n\x0etrace_metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\rtraceMetadata\"\xc0\x02\n\x16\x45valuateTracesResponse\x12I\n\x08progress\x18\x01 \x01(\x0b\x32+.agentevals.api.v1alpha1.EvaluationProgressH\x00R\x08progress\x12M\n\x0bperformance\x18\x02 \x01(\x0b\x32).agentevals.api.v1alpha1.GroupPerformanceH\x00R\x0bperformance\x12I\n\x0ctrace_result\x18\x03 \x01(\x0b\x32$.agentevals.api.v1alpha1.TraceResultH\x00R\x0btraceResult\x12\x38\n\x04\x64one\x18\x04 \x01(\x0b\x32\".agentevals.api.v1alpha1.RunResultH\x00R\x04\x64oneB\x07\n\x05\x65vent*\xa6\x01\n\x13TrajectoryMatchType\x12%\n!TRAJECTORY_MATCH_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n\x1bTRAJECTORY_MATCH_TYPE_EXACT\x10\x01\x12\"\n\x1eTRAJECTORY_MATCH_TYPE_IN_ORDER\x10\x02\x12#\n\x1fTRAJECTORY_MATCH_TYPE_ANY_ORDER\x10\x03*\x97\x01\n\nEvalStatus\x12\x1b\n\x17\x45VAL_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12\x45VAL_STATUS_PASSED\x10\x01\x12\x16\n\x12\x45VAL_STATUS_FAILED\x10\x02\x12\x1d\n\x19\x45VAL_STATUS_NOT_EVALUATED\x10\x03\x12\x1d\n\x19\x45VAL_STATUS_INFORMATIONAL\x10\x04\x32\xf2\x01\n\x11\x45valuationService\x12h\n\x0bListMetrics\x12+.agentevals.api.v1alpha1.ListMetricsRequest\x1a,.agentevals.api.v1alpha1.ListMetricsResponse\x12s\n\x0e\x45valuateTraces\x12..agentevals.api.v1alpha1.EvaluateTracesRequest\x1a/.agentevals.api.v1alpha1.EvaluateTracesResponse0\x01\x42QZOgithub.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1;apiv1alpha1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(agentevals/api/v1alpha1/evaluation.proto\x12\x17\x61gentevals.api.v1alpha1\x1a$agentevals/api/v1alpha1/common.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x14\n\x12ListMetricsRequest\"\x91\x02\n\x06Metric\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n\x08\x63\x61tegory\x18\x02 \x01(\tR\x08\x63\x61tegory\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12*\n\x11requires_eval_set\x18\x04 \x01(\x08R\x0frequiresEvalSet\x12!\n\x0crequires_llm\x18\x05 \x01(\x08R\x0brequiresLlm\x12!\n\x0crequires_gcp\x18\x06 \x01(\x08R\x0brequiresGcp\x12)\n\x10requires_rubrics\x18\x07 \x01(\x08R\x0frequiresRubrics\x12\x18\n\x07working\x18\x08 \x01(\x08R\x07working\"P\n\x13ListMetricsResponse\x12\x39\n\x07metrics\x18\x01 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.MetricR\x07metrics\"\xe2\x02\n\x10\x42uiltinEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1f\n\x0bjudge_model\x18\x03 \x01(\tR\njudgeModel\x12`\n\x15trajectory_match_type\x18\x04 \x01(\x0e\x32,.agentevals.api.v1alpha1.TrajectoryMatchTypeR\x13trajectoryMatchType\x12%\n\x0e\x63redential_ref\x18\x05 \x01(\tR\rcredentialRef\x12$\n\x0ejudge_base_url\x18\x06 \x01(\tR\x0cjudgeBaseUrl\x12\x39\n\x07rubrics\x18\x07 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.RubricR\x07rubricsB\x0c\n\n_threshold\",\n\x06Rubric\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04text\x18\x02 \x01(\tR\x04text\"\xe0\x01\n\rCodeEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x12\n\x04path\x18\x06 \x01(\tR\x04pathB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xf8\x01\n\x0fRemoteEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x16\n\x06source\x18\x06 \x01(\tR\x06source\x12\x10\n\x03ref\x18\x07 \x01(\tR\x03refB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xdc\x01\n\tEvaluator\x12\x45\n\x07\x62uiltin\x18\x01 \x01(\x0b\x32).agentevals.api.v1alpha1.BuiltinEvaluatorH\x00R\x07\x62uiltin\x12<\n\x04\x63ode\x18\x02 \x01(\x0b\x32&.agentevals.api.v1alpha1.CodeEvaluatorH\x00R\x04\x63ode\x12\x42\n\x06remote\x18\x03 \x01(\x0b\x32(.agentevals.api.v1alpha1.RemoteEvaluatorH\x00R\x06remoteB\x06\n\x04kind\"\xb0\x02\n\nEvalParams\x12\x42\n\nevaluators\x18\x01 \x03(\x0b\x32\".agentevals.api.v1alpha1.EvaluatorR\nevaluators\x12\x37\n\x15max_concurrent_traces\x18\x02 \x01(\x05H\x00R\x13maxConcurrentTraces\x88\x01\x01\x12\x35\n\x14max_concurrent_evals\x18\x03 \x01(\x05H\x01R\x12maxConcurrentEvals\x88\x01\x01\x12;\n\x08group_by\x18\x04 \x01(\x0e\x32 .agentevals.api.v1alpha1.GroupByR\x07groupByB\x18\n\x16_max_concurrent_tracesB\x17\n\x15_max_concurrent_evals\"t\n\x10InvocationResult\x12\x19\n\x05score\x18\x01 \x01(\x01H\x00R\x05score\x88\x01\x01\x12;\n\x06status\x18\x02 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\x06statusB\x08\n\x06_score\"\xad\x03\n\x0cMetricResult\x12\x1f\n\x0bmetric_name\x18\x01 \x01(\tR\nmetricName\x12\x19\n\x05score\x18\x02 \x01(\x01H\x00R\x05score\x88\x01\x01\x12\x44\n\x0b\x65val_status\x18\x03 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\nevalStatus\x12P\n\x0eper_invocation\x18\x04 \x03(\x0b\x32).agentevals.api.v1alpha1.InvocationResultR\rperInvocation\x12\x19\n\x05\x65rror\x18\x05 \x01(\tH\x01R\x05\x65rror\x88\x01\x01\x12\"\n\nerror_type\x18\x06 \x01(\tH\x02R\terrorType\x88\x01\x01\x12\x31\n\x07\x64\x65tails\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructR\x07\x64\x65tails\x12$\n\x0b\x64uration_ms\x18\x08 \x01(\x01H\x03R\ndurationMs\x88\x01\x01\x42\x08\n\x06_scoreB\x08\n\x06_errorB\r\n\x0b_error_typeB\x0e\n\x0c_duration_ms\"\x97\x01\n\x07TurnRef\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\x17\n\x07span_id\x18\x02 \x01(\tR\x06spanId\x12,\n\x12\x65valuated_trace_id\x18\x03 \x01(\tR\x10\x65valuatedTraceId\x12*\n\x11\x65valuated_span_id\x18\x04 \x01(\tR\x0f\x65valuatedSpanId\"\xc8\x04\n\x0bTraceResult\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\'\n\x0fnum_invocations\x18\x02 \x01(\x05R\x0enumInvocations\x12L\n\x0emetric_results\x18\x03 \x03(\x0b\x32%.agentevals.api.v1alpha1.MetricResultR\rmetricResults\x12/\n\x13\x63onversion_warnings\x18\x04 \x03(\tR\x12\x63onversionWarnings\x12H\n\x13performance_metrics\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12!\n\x0cservice_name\x18\x06 \x01(\tR\x0bserviceName\x12\x1d\n\nagent_name\x18\x07 \x01(\tR\tagentName\x12\x1b\n\ttrace_ids\x18\x08 \x03(\tR\x08traceIds\x12\'\n\x0f\x63onversation_id\x18\t \x01(\tR\x0e\x63onversationId\x12\x1b\n\tgroup_key\x18\n \x01(\tR\x08groupKey\x12 \n\x0c\x65val_case_id\x18\x0b \x01(\tR\nevalCaseId\x12&\n\x0f\x65val_case_match\x18\x0c \x01(\tR\revalCaseMatch\x12=\n\tturn_refs\x18\r \x03(\x0b\x32 .agentevals.api.v1alpha1.TurnRefR\x08turnRefs\"\xcf\x01\n\tRunResult\x12I\n\rtrace_results\x18\x01 \x03(\x0b\x32$.agentevals.api.v1alpha1.TraceResultR\x0ctraceResults\x12\x16\n\x06\x65rrors\x18\x02 \x03(\tR\x06\x65rrors\x12H\n\x13performance_metrics\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12\x15\n\x06run_id\x18\x04 \x01(\tR\x05runId\"\xa3\x03\n\x15\x45valuateTracesRequest\x12;\n\x06traces\x18\x01 \x01(\x0b\x32#.agentevals.api.v1alpha1.TraceInputR\x06traces\x12;\n\x0c\x61\x64k_eval_set\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructH\x00R\nadkEvalSet\x12;\n\x06params\x18\x03 \x01(\x0b\x32#.agentevals.api.v1alpha1.EvalParamsR\x06params\x12k\n\x0f\x63redential_refs\x18\x04 \x03(\x0b\x32\x42.agentevals.api.v1alpha1.EvaluateTracesRequest.CredentialRefsEntryR\x0e\x63redentialRefs\x1aZ\n\x13\x43redentialRefsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12-\n\x05value\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x05value:\x02\x38\x01\x42\n\n\x08\x65val_set\".\n\x12\x45valuationProgress\x12\x18\n\x07message\x18\x01 \x01(\tR\x07message\"\xb7\x01\n\x10GroupPerformance\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12H\n\x13performance_metrics\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12>\n\x0etrace_metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\rtraceMetadata\"\xc0\x02\n\x16\x45valuateTracesResponse\x12I\n\x08progress\x18\x01 \x01(\x0b\x32+.agentevals.api.v1alpha1.EvaluationProgressH\x00R\x08progress\x12M\n\x0bperformance\x18\x02 \x01(\x0b\x32).agentevals.api.v1alpha1.GroupPerformanceH\x00R\x0bperformance\x12I\n\x0ctrace_result\x18\x03 \x01(\x0b\x32$.agentevals.api.v1alpha1.TraceResultH\x00R\x0btraceResult\x12\x38\n\x04\x64one\x18\x04 \x01(\x0b\x32\".agentevals.api.v1alpha1.RunResultH\x00R\x04\x64oneB\x07\n\x05\x65vent*\xa6\x01\n\x13TrajectoryMatchType\x12%\n!TRAJECTORY_MATCH_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n\x1bTRAJECTORY_MATCH_TYPE_EXACT\x10\x01\x12\"\n\x1eTRAJECTORY_MATCH_TYPE_IN_ORDER\x10\x02\x12#\n\x1fTRAJECTORY_MATCH_TYPE_ANY_ORDER\x10\x03*\x97\x01\n\nEvalStatus\x12\x1b\n\x17\x45VAL_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12\x45VAL_STATUS_PASSED\x10\x01\x12\x16\n\x12\x45VAL_STATUS_FAILED\x10\x02\x12\x1d\n\x19\x45VAL_STATUS_NOT_EVALUATED\x10\x03\x12\x1d\n\x19\x45VAL_STATUS_INFORMATIONAL\x10\x04\x32\xf2\x01\n\x11\x45valuationService\x12h\n\x0bListMetrics\x12+.agentevals.api.v1alpha1.ListMetricsRequest\x1a,.agentevals.api.v1alpha1.ListMetricsResponse\x12s\n\x0e\x45valuateTraces\x12..agentevals.api.v1alpha1.EvaluateTracesRequest\x1a/.agentevals.api.v1alpha1.EvaluateTracesResponse0\x01\x42QZOgithub.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1;apiv1alpha1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -36,10 +36,10 @@ _globals['DESCRIPTOR']._serialized_options = b'ZOgithub.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1;apiv1alpha1' _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._loaded_options = None _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_options = b'8\001' - _globals['_TRAJECTORYMATCHTYPE']._serialized_start=4318 - _globals['_TRAJECTORYMATCHTYPE']._serialized_end=4484 - _globals['_EVALSTATUS']._serialized_start=4487 - _globals['_EVALSTATUS']._serialized_end=4638 + _globals['_TRAJECTORYMATCHTYPE']._serialized_start=4409 + _globals['_TRAJECTORYMATCHTYPE']._serialized_end=4575 + _globals['_EVALSTATUS']._serialized_start=4578 + _globals['_EVALSTATUS']._serialized_end=4729 _globals['_LISTMETRICSREQUEST']._serialized_start=137 _globals['_LISTMETRICSREQUEST']._serialized_end=157 _globals['_METRIC']._serialized_start=160 @@ -62,22 +62,22 @@ _globals['_INVOCATIONRESULT']._serialized_end=2044 _globals['_METRICRESULT']._serialized_start=2047 _globals['_METRICRESULT']._serialized_end=2476 - _globals['_TURNREF']._serialized_start=2478 - _globals['_TURNREF']._serialized_end=2539 - _globals['_TRACERESULT']._serialized_start=2542 - _globals['_TRACERESULT']._serialized_end=3126 - _globals['_RUNRESULT']._serialized_start=3129 - _globals['_RUNRESULT']._serialized_end=3336 - _globals['_EVALUATETRACESREQUEST']._serialized_start=3339 - _globals['_EVALUATETRACESREQUEST']._serialized_end=3758 - _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_start=3656 - _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_end=3746 - _globals['_EVALUATIONPROGRESS']._serialized_start=3760 - _globals['_EVALUATIONPROGRESS']._serialized_end=3806 - _globals['_GROUPPERFORMANCE']._serialized_start=3809 - _globals['_GROUPPERFORMANCE']._serialized_end=3992 - _globals['_EVALUATETRACESRESPONSE']._serialized_start=3995 - _globals['_EVALUATETRACESRESPONSE']._serialized_end=4315 - _globals['_EVALUATIONSERVICE']._serialized_start=4641 - _globals['_EVALUATIONSERVICE']._serialized_end=4883 + _globals['_TURNREF']._serialized_start=2479 + _globals['_TURNREF']._serialized_end=2630 + _globals['_TRACERESULT']._serialized_start=2633 + _globals['_TRACERESULT']._serialized_end=3217 + _globals['_RUNRESULT']._serialized_start=3220 + _globals['_RUNRESULT']._serialized_end=3427 + _globals['_EVALUATETRACESREQUEST']._serialized_start=3430 + _globals['_EVALUATETRACESREQUEST']._serialized_end=3849 + _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_start=3747 + _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_end=3837 + _globals['_EVALUATIONPROGRESS']._serialized_start=3851 + _globals['_EVALUATIONPROGRESS']._serialized_end=3897 + _globals['_GROUPPERFORMANCE']._serialized_start=3900 + _globals['_GROUPPERFORMANCE']._serialized_end=4083 + _globals['_EVALUATETRACESRESPONSE']._serialized_start=4086 + _globals['_EVALUATETRACESRESPONSE']._serialized_end=4406 + _globals['_EVALUATIONSERVICE']._serialized_start=4732 + _globals['_EVALUATIONSERVICE']._serialized_end=4974 # @@protoc_insertion_point(module_scope) diff --git a/src/agentevals/api/v1alpha1/evaluation_pb2.pyi b/src/agentevals/api/v1alpha1/evaluation_pb2.pyi index be603f0..02ec6cc 100644 --- a/src/agentevals/api/v1alpha1/evaluation_pb2.pyi +++ b/src/agentevals/api/v1alpha1/evaluation_pb2.pyi @@ -174,12 +174,16 @@ class MetricResult(_message.Message): def __init__(self, metric_name: _Optional[str] = ..., score: _Optional[float] = ..., eval_status: _Optional[_Union[EvalStatus, str]] = ..., per_invocation: _Optional[_Iterable[_Union[InvocationResult, _Mapping]]] = ..., error: _Optional[str] = ..., error_type: _Optional[str] = ..., details: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., duration_ms: _Optional[float] = ...) -> None: ... class TurnRef(_message.Message): - __slots__ = ("trace_id", "span_id") + __slots__ = ("trace_id", "span_id", "evaluated_trace_id", "evaluated_span_id") TRACE_ID_FIELD_NUMBER: _ClassVar[int] SPAN_ID_FIELD_NUMBER: _ClassVar[int] + EVALUATED_TRACE_ID_FIELD_NUMBER: _ClassVar[int] + EVALUATED_SPAN_ID_FIELD_NUMBER: _ClassVar[int] trace_id: str span_id: str - def __init__(self, trace_id: _Optional[str] = ..., span_id: _Optional[str] = ...) -> None: ... + evaluated_trace_id: str + evaluated_span_id: str + def __init__(self, trace_id: _Optional[str] = ..., span_id: _Optional[str] = ..., evaluated_trace_id: _Optional[str] = ..., evaluated_span_id: _Optional[str] = ...) -> None: ... class TraceResult(_message.Message): __slots__ = ("trace_id", "num_invocations", "metric_results", "conversion_warnings", "performance_metrics", "service_name", "agent_name", "trace_ids", "conversation_id", "group_key", "eval_case_id", "eval_case_match", "turn_refs") diff --git a/src/agentevals/evaluation_events.py b/src/agentevals/evaluation_events.py index fa9e1a4..9c1920b 100644 --- a/src/agentevals/evaluation_events.py +++ b/src/agentevals/evaluation_events.py @@ -109,11 +109,12 @@ def _explanation(metric: MetricResult) -> str | None: return None -def _evaluated_span(turn: Turn) -> tuple[str, str]: +def evaluated_span(turn: Turn) -> tuple[str, str]: """The GenAI span a turn's results attach to: its agent or workflow anchor, else its final call. A root anchored turn's anchor can be a non GenAI span (an HTTP server span, say); the event - should hang off the GenAI operation being evaluated. + should hang off the GenAI operation being evaluated. The API reports the same span in each + ``TurnRef``, so its clients attach results where these records go. """ calls = turn.llm_calls if turn.anchor_kind != "root" or not calls: @@ -225,7 +226,7 @@ def _emit(self, conversation, metrics, evaluators, spans, eval_case_id, eval_set items = [(turns[-1], metric.score, metric.eval_status)] for turn, score, status in items: - ref = _evaluated_span(turn) + ref = evaluated_span(turn) span = spans.get(ref) attrs = dict(base) if metric.error: diff --git a/src/agentevals/runner.py b/src/agentevals/runner.py index dee6109..ad49beb 100644 --- a/src/agentevals/runner.py +++ b/src/agentevals/runner.py @@ -23,7 +23,7 @@ from .genai.extract import extract_conversation from .genai.grouping import group_traces, has_session_name from .genai.matching import EVAL_CASE_ID, ExpectedConversation, select_case -from .genai.model import Conversation +from .genai.model import Conversation, Turn from .loader import load_traces from .otel.identity import coerce_key from .otel.model import Trace @@ -61,10 +61,25 @@ class MetricResult(BaseModel): class TurnRef(BaseModel): + """A judged turn: its anchor span, and the span its results attach to (see + :func:`.evaluation_events.evaluated_span`).""" + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) trace_id: str span_id: str + evaluated_trace_id: str + evaluated_span_id: str + + @classmethod + def of(cls, turn: Turn) -> TurnRef: + evaluated = emit.evaluated_span(turn) + return cls( + trace_id=turn.ref.trace_id, + span_id=turn.ref.span_id, + evaluated_trace_id=evaluated[0], + evaluated_span_id=evaluated[1], + ) class TraceResult(BaseModel): @@ -258,7 +273,7 @@ async def _evaluate_group( trace_result.agent_name = identity["agent_name"] trace_result.num_invocations = len(conversation.turns) trace_result.conversation_id = conversation.conversation_id - trace_result.turn_refs = [TurnRef(trace_id=t.ref.trace_id, span_id=t.ref.span_id) for t in conversation.turns] + trace_result.turn_refs = [TurnRef.of(t) for t in conversation.turns] trace_result.conversion_warnings = sorted( {w for trace in traces for w in trace.warnings} | {w.reason for t in conversation.turns for w in t.warnings} ) diff --git a/tests/otel/test_emit.py b/tests/otel/test_emit.py index 6057051..af316a5 100644 --- a/tests/otel/test_emit.py +++ b/tests/otel/test_emit.py @@ -7,14 +7,16 @@ import pytest from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter +from agentevals import custom_evaluators from agentevals import evaluation_events as emit -from agentevals.config import BuiltinMetricDef, CodeEvaluatorDef +from agentevals.api.rpc import convert +from agentevals.config import BuiltinMetricDef, CodeEvaluatorDef, EvalParams from agentevals.genai.extract import extract_conversation from agentevals.otel.decode import decode_json_document from agentevals.otel.model import build_traces -from agentevals.runner import MetricResult +from agentevals.runner import MetricResult, run_evaluation_from_traces -from .builders import agent, assistant, chat, request, sid, span, tid, user +from .builders import agent, assistant, chat, request, sid, span, tid, tool_result, user ALLOWED = { "gen_ai.evaluation.name", @@ -224,6 +226,62 @@ def test_judge_metrics_are_typed_llm_judge(self): assert attrs["agentevals.evaluator.type"] == "llm_judge" +ROLL = ("call_1", "roll_die", {"sides": 20}) +SHAPES = { + "agent": ("a1", "a1", [agent(span_id="a1"), chat(parent="a1")]), + "non_genai_root": ( + "http", + "c2", + [ + span(span_id="http", name="POST /chat", start=1, end=9_000), + chat(span_id="c1", parent="http", start=100, end=200, outputs=[assistant("first")]), + chat(span_id="c2", parent="http", start=300, end=400, outputs=[assistant("second")]), + ], + ), + "continuation_across_traces": ( + "http", + "c2", + [ + span(trace="a", span_id="http", name="POST /chat", start=1, end=2_500), + chat(trace="a", span_id="c1", parent="http", inputs=[user("Roll")], outputs=[assistant(tool_calls=[ROLL])]), + chat( + trace="b", + span_id="c2", + start=3_000, + end=4_000, + inputs=[user("Roll"), assistant(tool_calls=[ROLL]), tool_result("call_1", "roll_die", 7)], + outputs=[assistant("You rolled 7")], + ), + ], + ), +} + + +class TestTurnRefs: + @pytest.mark.parametrize("shape", sorted(SHAPES)) + async def test_the_api_names_the_span_the_record_attaches_to(self, monkeypatch, shape): + exporter = InMemoryLogRecordExporter() + emitter = emit.EvaluationEmitter(exporter) + monkeypatch.setattr(emit, "get_emitter", lambda: emitter) + + async def judge(evaluator_def, **_): + return MetricResult(metric_name=evaluator_def.name, score=1.0, eval_status="PASSED") + + monkeypatch.setattr(custom_evaluators, "evaluate_custom_evaluator", judge) + anchor, evaluated, spans = SHAPES[shape] + decoded = decode_json_document(request(spans, resource={"agentevals.session_name": "s"}), strict=True) + traces, _ = build_traces(decoded.spans, decoded.logs) + params = EvalParams(evaluators=[CodeEvaluatorDef(name="judge", path="j.py")]) + result = await run_evaluation_from_traces(traces, params) + emitter.flush() + + [trace_result] = result.trace_results + [ref] = convert.trace_result_to_pb(trace_result).turn_refs + [record] = [r.log_record for r in exporter.get_finished_logs()] + assert (ref.evaluated_trace_id, ref.evaluated_span_id) == _ref(record) + assert (ref.span_id, ref.evaluated_span_id) == (sid(anchor), sid(evaluated)) + + class TestFailures: def test_an_export_failure_never_raises_and_logs_rarely(self, caplog, monkeypatch): monkeypatch.setattr(emit.time, "monotonic", lambda: 1.0) diff --git a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts index 17eff46..b058b9e 100644 --- a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts +++ b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts @@ -448,6 +448,8 @@ export declare const MetricResultSchema: GenMessage; */ export declare type TurnRef = Message<"agentevals.api.v1alpha1.TurnRef"> & { /** + * The turn's anchor: its agent or workflow span, else the root span of its trace. + * * @generated from field: string trace_id = 1; */ traceId: string; @@ -456,6 +458,19 @@ export declare type TurnRef = Message<"agentevals.api.v1alpha1.TurnRef"> & { * @generated from field: string span_id = 2; */ spanId: string; + + /** + * The span the turn's gen_ai.evaluation.result records attach to: the anchor, or the turn's + * final model call when the anchor is not a GenAI span. Attach results here. + * + * @generated from field: string evaluated_trace_id = 3; + */ + evaluatedTraceId: string; + + /** + * @generated from field: string evaluated_span_id = 4; + */ + evaluatedSpanId: string; }; /** diff --git a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js index 9e5c6ba..b52418b 100644 --- a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js +++ b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js @@ -10,7 +10,7 @@ import { file_google_protobuf_struct } from "@bufbuild/protobuf/wkt"; * Describes the file agentevals/api/v1alpha1/evaluation.proto. */ export const file_agentevals_api_v1alpha1_evaluation = /*@__PURE__*/ - fileDesc("CihhZ2VudGV2YWxzL2FwaS92MWFscGhhMS9ldmFsdWF0aW9uLnByb3RvEhdhZ2VudGV2YWxzLmFwaS52MWFscGhhMSIUChJMaXN0TWV0cmljc1JlcXVlc3QirwEKBk1ldHJpYxIMCgRuYW1lGAEgASgJEhAKCGNhdGVnb3J5GAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhkKEXJlcXVpcmVzX2V2YWxfc2V0GAQgASgIEhQKDHJlcXVpcmVzX2xsbRgFIAEoCBIUCgxyZXF1aXJlc19nY3AYBiABKAgSGAoQcmVxdWlyZXNfcnVicmljcxgHIAEoCBIPCgd3b3JraW5nGAggASgIIkcKE0xpc3RNZXRyaWNzUmVzcG9uc2USMAoHbWV0cmljcxgBIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLk1ldHJpYyKKAgoQQnVpbHRpbkV2YWx1YXRvchIMCgRuYW1lGAEgASgJEhYKCXRocmVzaG9sZBgCIAEoAUgAiAEBEhMKC2p1ZGdlX21vZGVsGAMgASgJEksKFXRyYWplY3RvcnlfbWF0Y2hfdHlwZRgEIAEoDjIsLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlRyYWplY3RvcnlNYXRjaFR5cGUSFgoOY3JlZGVudGlhbF9yZWYYBSABKAkSFgoOanVkZ2VfYmFzZV91cmwYBiABKAkSMAoHcnVicmljcxgHIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlJ1YnJpY0IMCgpfdGhyZXNob2xkIiIKBlJ1YnJpYxIKCgJpZBgBIAEoCRIMCgR0ZXh0GAIgASgJIq4BCg1Db2RlRXZhbHVhdG9yEgwKBG5hbWUYASABKAkSFgoJdGhyZXNob2xkGAIgASgBSACIAQESFAoHdGltZW91dBgDIAEoBUgBiAEBEicKBmNvbmZpZxgEIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSEAoIZXhlY3V0b3IYBSABKAkSDAoEcGF0aBgGIAEoCUIMCgpfdGhyZXNob2xkQgoKCF90aW1lb3V0Ir8BCg9SZW1vdGVFdmFsdWF0b3ISDAoEbmFtZRgBIAEoCRIWCgl0aHJlc2hvbGQYAiABKAFIAIgBARIUCgd0aW1lb3V0GAMgASgFSAGIAQESJwoGY29uZmlnGAQgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIQCghleGVjdXRvchgFIAEoCRIOCgZzb3VyY2UYBiABKAkSCwoDcmVmGAcgASgJQgwKCl90aHJlc2hvbGRCCgoIX3RpbWVvdXQixQEKCUV2YWx1YXRvchI8CgdidWlsdGluGAEgASgLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuQnVpbHRpbkV2YWx1YXRvckgAEjYKBGNvZGUYAiABKAsyJi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Db2RlRXZhbHVhdG9ySAASOgoGcmVtb3RlGAMgASgLMiguYWdlbnRldmFscy5hcGkudjFhbHBoYTEuUmVtb3RlRXZhbHVhdG9ySABCBgoEa2luZCLyAQoKRXZhbFBhcmFtcxI2CgpldmFsdWF0b3JzGAEgAygLMiIuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbHVhdG9yEiIKFW1heF9jb25jdXJyZW50X3RyYWNlcxgCIAEoBUgAiAEBEiEKFG1heF9jb25jdXJyZW50X2V2YWxzGAMgASgFSAGIAQESMgoIZ3JvdXBfYnkYBCABKA4yIC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Hcm91cEJ5QhgKFl9tYXhfY29uY3VycmVudF90cmFjZXNCFwoVX21heF9jb25jdXJyZW50X2V2YWxzImUKEEludm9jYXRpb25SZXN1bHQSEgoFc2NvcmUYASABKAFIAIgBARIzCgZzdGF0dXMYAiABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzQggKBl9zY29yZSLYAgoMTWV0cmljUmVzdWx0EhMKC21ldHJpY19uYW1lGAEgASgJEhIKBXNjb3JlGAIgASgBSACIAQESOAoLZXZhbF9zdGF0dXMYAyABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzEkEKDnBlcl9pbnZvY2F0aW9uGAQgAygLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuSW52b2NhdGlvblJlc3VsdBISCgVlcnJvchgFIAEoCUgBiAEBEhcKCmVycm9yX3R5cGUYBiABKAlIAogBARIoCgdkZXRhaWxzGAcgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIYCgtkdXJhdGlvbl9tcxgIIAEoAUgDiAEBQggKBl9zY29yZUIICgZfZXJyb3JCDQoLX2Vycm9yX3R5cGVCDgoMX2R1cmF0aW9uX21zIiwKB1R1cm5SZWYSEAoIdHJhY2VfaWQYASABKAkSDwoHc3Bhbl9pZBgCIAEoCSKXAwoLVHJhY2VSZXN1bHQSEAoIdHJhY2VfaWQYASABKAkSFwoPbnVtX2ludm9jYXRpb25zGAIgASgFEj0KDm1ldHJpY19yZXN1bHRzGAMgAygLMiUuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuTWV0cmljUmVzdWx0EhsKE2NvbnZlcnNpb25fd2FybmluZ3MYBCADKAkSNAoTcGVyZm9ybWFuY2VfbWV0cmljcxgFIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSFAoMc2VydmljZV9uYW1lGAYgASgJEhIKCmFnZW50X25hbWUYByABKAkSEQoJdHJhY2VfaWRzGAggAygJEhcKD2NvbnZlcnNhdGlvbl9pZBgJIAEoCRIRCglncm91cF9rZXkYCiABKAkSFAoMZXZhbF9jYXNlX2lkGAsgASgJEhcKD2V2YWxfY2FzZV9tYXRjaBgMIAEoCRIzCgl0dXJuX3JlZnMYDSADKAsyIC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5UdXJuUmVmIp4BCglSdW5SZXN1bHQSOwoNdHJhY2VfcmVzdWx0cxgBIAMoCzIkLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlRyYWNlUmVzdWx0Eg4KBmVycm9ycxgCIAMoCRI0ChNwZXJmb3JtYW5jZV9tZXRyaWNzGAMgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIOCgZydW5faWQYBCABKAki6wIKFUV2YWx1YXRlVHJhY2VzUmVxdWVzdBIzCgZ0cmFjZXMYASABKAsyIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5UcmFjZUlucHV0Ei8KDGFka19ldmFsX3NldBgCIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3RIABIzCgZwYXJhbXMYAyABKAsyIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsUGFyYW1zElsKD2NyZWRlbnRpYWxfcmVmcxgEIAMoCzJCLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkV2YWx1YXRlVHJhY2VzUmVxdWVzdC5DcmVkZW50aWFsUmVmc0VudHJ5Gk4KE0NyZWRlbnRpYWxSZWZzRW50cnkSCwoDa2V5GAEgASgJEiYKBXZhbHVlGAIgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdDoCOAFCCgoIZXZhbF9zZXQiJQoSRXZhbHVhdGlvblByb2dyZXNzEg8KB21lc3NhZ2UYASABKAkiiwEKEEdyb3VwUGVyZm9ybWFuY2USEAoIdHJhY2VfaWQYASABKAkSNAoTcGVyZm9ybWFuY2VfbWV0cmljcxgCIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSLwoOdHJhY2VfbWV0YWRhdGEYAyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0IpYCChZFdmFsdWF0ZVRyYWNlc1Jlc3BvbnNlEj8KCHByb2dyZXNzGAEgASgLMisuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbHVhdGlvblByb2dyZXNzSAASQAoLcGVyZm9ybWFuY2UYAiABKAsyKS5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Hcm91cFBlcmZvcm1hbmNlSAASPAoMdHJhY2VfcmVzdWx0GAMgASgLMiQuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuVHJhY2VSZXN1bHRIABIyCgRkb25lGAQgASgLMiIuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuUnVuUmVzdWx0SABCBwoFZXZlbnQqpgEKE1RyYWplY3RvcnlNYXRjaFR5cGUSJQohVFJBSkVDVE9SWV9NQVRDSF9UWVBFX1VOU1BFQ0lGSUVEEAASHwobVFJBSkVDVE9SWV9NQVRDSF9UWVBFX0VYQUNUEAESIgoeVFJBSkVDVE9SWV9NQVRDSF9UWVBFX0lOX09SREVSEAISIwofVFJBSkVDVE9SWV9NQVRDSF9UWVBFX0FOWV9PUkRFUhADKpcBCgpFdmFsU3RhdHVzEhsKF0VWQUxfU1RBVFVTX1VOU1BFQ0lGSUVEEAASFgoSRVZBTF9TVEFUVVNfUEFTU0VEEAESFgoSRVZBTF9TVEFUVVNfRkFJTEVEEAISHQoZRVZBTF9TVEFUVVNfTk9UX0VWQUxVQVRFRBADEh0KGUVWQUxfU1RBVFVTX0lORk9STUFUSU9OQUwQBDLyAQoRRXZhbHVhdGlvblNlcnZpY2USaAoLTGlzdE1ldHJpY3MSKy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5MaXN0TWV0cmljc1JlcXVlc3QaLC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5MaXN0TWV0cmljc1Jlc3BvbnNlEnMKDkV2YWx1YXRlVHJhY2VzEi4uYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbHVhdGVUcmFjZXNSZXF1ZXN0Gi8uYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbHVhdGVUcmFjZXNSZXNwb25zZTABQlFaT2dpdGh1Yi5jb20vYWdlbnRldmFscy1kZXYvYWdlbnRldmFscy9nZW4vZ28vYWdlbnRldmFscy9hcGkvdjFhbHBoYTE7YXBpdjFhbHBoYTFiBnByb3RvMw", [file_agentevals_api_v1alpha1_common, file_google_protobuf_struct]); + fileDesc("CihhZ2VudGV2YWxzL2FwaS92MWFscGhhMS9ldmFsdWF0aW9uLnByb3RvEhdhZ2VudGV2YWxzLmFwaS52MWFscGhhMSIUChJMaXN0TWV0cmljc1JlcXVlc3QirwEKBk1ldHJpYxIMCgRuYW1lGAEgASgJEhAKCGNhdGVnb3J5GAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhkKEXJlcXVpcmVzX2V2YWxfc2V0GAQgASgIEhQKDHJlcXVpcmVzX2xsbRgFIAEoCBIUCgxyZXF1aXJlc19nY3AYBiABKAgSGAoQcmVxdWlyZXNfcnVicmljcxgHIAEoCBIPCgd3b3JraW5nGAggASgIIkcKE0xpc3RNZXRyaWNzUmVzcG9uc2USMAoHbWV0cmljcxgBIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLk1ldHJpYyKKAgoQQnVpbHRpbkV2YWx1YXRvchIMCgRuYW1lGAEgASgJEhYKCXRocmVzaG9sZBgCIAEoAUgAiAEBEhMKC2p1ZGdlX21vZGVsGAMgASgJEksKFXRyYWplY3RvcnlfbWF0Y2hfdHlwZRgEIAEoDjIsLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlRyYWplY3RvcnlNYXRjaFR5cGUSFgoOY3JlZGVudGlhbF9yZWYYBSABKAkSFgoOanVkZ2VfYmFzZV91cmwYBiABKAkSMAoHcnVicmljcxgHIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlJ1YnJpY0IMCgpfdGhyZXNob2xkIiIKBlJ1YnJpYxIKCgJpZBgBIAEoCRIMCgR0ZXh0GAIgASgJIq4BCg1Db2RlRXZhbHVhdG9yEgwKBG5hbWUYASABKAkSFgoJdGhyZXNob2xkGAIgASgBSACIAQESFAoHdGltZW91dBgDIAEoBUgBiAEBEicKBmNvbmZpZxgEIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSEAoIZXhlY3V0b3IYBSABKAkSDAoEcGF0aBgGIAEoCUIMCgpfdGhyZXNob2xkQgoKCF90aW1lb3V0Ir8BCg9SZW1vdGVFdmFsdWF0b3ISDAoEbmFtZRgBIAEoCRIWCgl0aHJlc2hvbGQYAiABKAFIAIgBARIUCgd0aW1lb3V0GAMgASgFSAGIAQESJwoGY29uZmlnGAQgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIQCghleGVjdXRvchgFIAEoCRIOCgZzb3VyY2UYBiABKAkSCwoDcmVmGAcgASgJQgwKCl90aHJlc2hvbGRCCgoIX3RpbWVvdXQixQEKCUV2YWx1YXRvchI8CgdidWlsdGluGAEgASgLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuQnVpbHRpbkV2YWx1YXRvckgAEjYKBGNvZGUYAiABKAsyJi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Db2RlRXZhbHVhdG9ySAASOgoGcmVtb3RlGAMgASgLMiguYWdlbnRldmFscy5hcGkudjFhbHBoYTEuUmVtb3RlRXZhbHVhdG9ySABCBgoEa2luZCLyAQoKRXZhbFBhcmFtcxI2CgpldmFsdWF0b3JzGAEgAygLMiIuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbHVhdG9yEiIKFW1heF9jb25jdXJyZW50X3RyYWNlcxgCIAEoBUgAiAEBEiEKFG1heF9jb25jdXJyZW50X2V2YWxzGAMgASgFSAGIAQESMgoIZ3JvdXBfYnkYBCABKA4yIC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Hcm91cEJ5QhgKFl9tYXhfY29uY3VycmVudF90cmFjZXNCFwoVX21heF9jb25jdXJyZW50X2V2YWxzImUKEEludm9jYXRpb25SZXN1bHQSEgoFc2NvcmUYASABKAFIAIgBARIzCgZzdGF0dXMYAiABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzQggKBl9zY29yZSLYAgoMTWV0cmljUmVzdWx0EhMKC21ldHJpY19uYW1lGAEgASgJEhIKBXNjb3JlGAIgASgBSACIAQESOAoLZXZhbF9zdGF0dXMYAyABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzEkEKDnBlcl9pbnZvY2F0aW9uGAQgAygLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuSW52b2NhdGlvblJlc3VsdBISCgVlcnJvchgFIAEoCUgBiAEBEhcKCmVycm9yX3R5cGUYBiABKAlIAogBARIoCgdkZXRhaWxzGAcgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIYCgtkdXJhdGlvbl9tcxgIIAEoAUgDiAEBQggKBl9zY29yZUIICgZfZXJyb3JCDQoLX2Vycm9yX3R5cGVCDgoMX2R1cmF0aW9uX21zImMKB1R1cm5SZWYSEAoIdHJhY2VfaWQYASABKAkSDwoHc3Bhbl9pZBgCIAEoCRIaChJldmFsdWF0ZWRfdHJhY2VfaWQYAyABKAkSGQoRZXZhbHVhdGVkX3NwYW5faWQYBCABKAkilwMKC1RyYWNlUmVzdWx0EhAKCHRyYWNlX2lkGAEgASgJEhcKD251bV9pbnZvY2F0aW9ucxgCIAEoBRI9Cg5tZXRyaWNfcmVzdWx0cxgDIAMoCzIlLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLk1ldHJpY1Jlc3VsdBIbChNjb252ZXJzaW9uX3dhcm5pbmdzGAQgAygJEjQKE3BlcmZvcm1hbmNlX21ldHJpY3MYBSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhQKDHNlcnZpY2VfbmFtZRgGIAEoCRISCgphZ2VudF9uYW1lGAcgASgJEhEKCXRyYWNlX2lkcxgIIAMoCRIXCg9jb252ZXJzYXRpb25faWQYCSABKAkSEQoJZ3JvdXBfa2V5GAogASgJEhQKDGV2YWxfY2FzZV9pZBgLIAEoCRIXCg9ldmFsX2Nhc2VfbWF0Y2gYDCABKAkSMwoJdHVybl9yZWZzGA0gAygLMiAuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuVHVyblJlZiKeAQoJUnVuUmVzdWx0EjsKDXRyYWNlX3Jlc3VsdHMYASADKAsyJC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5UcmFjZVJlc3VsdBIOCgZlcnJvcnMYAiADKAkSNAoTcGVyZm9ybWFuY2VfbWV0cmljcxgDIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSDgoGcnVuX2lkGAQgASgJIusCChVFdmFsdWF0ZVRyYWNlc1JlcXVlc3QSMwoGdHJhY2VzGAEgASgLMiMuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuVHJhY2VJbnB1dBIvCgxhZGtfZXZhbF9zZXQYAiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0SAASMwoGcGFyYW1zGAMgASgLMiMuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbFBhcmFtcxJbCg9jcmVkZW50aWFsX3JlZnMYBCADKAsyQi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsdWF0ZVRyYWNlc1JlcXVlc3QuQ3JlZGVudGlhbFJlZnNFbnRyeRpOChNDcmVkZW50aWFsUmVmc0VudHJ5EgsKA2tleRgBIAEoCRImCgV2YWx1ZRgCIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3Q6AjgBQgoKCGV2YWxfc2V0IiUKEkV2YWx1YXRpb25Qcm9ncmVzcxIPCgdtZXNzYWdlGAEgASgJIosBChBHcm91cFBlcmZvcm1hbmNlEhAKCHRyYWNlX2lkGAEgASgJEjQKE3BlcmZvcm1hbmNlX21ldHJpY3MYAiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0Ei8KDnRyYWNlX21ldGFkYXRhGAMgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdCKWAgoWRXZhbHVhdGVUcmFjZXNSZXNwb25zZRI/Cghwcm9ncmVzcxgBIAEoCzIrLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkV2YWx1YXRpb25Qcm9ncmVzc0gAEkAKC3BlcmZvcm1hbmNlGAIgASgLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuR3JvdXBQZXJmb3JtYW5jZUgAEjwKDHRyYWNlX3Jlc3VsdBgDIAEoCzIkLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlRyYWNlUmVzdWx0SAASMgoEZG9uZRgEIAEoCzIiLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlJ1blJlc3VsdEgAQgcKBWV2ZW50KqYBChNUcmFqZWN0b3J5TWF0Y2hUeXBlEiUKIVRSQUpFQ1RPUllfTUFUQ0hfVFlQRV9VTlNQRUNJRklFRBAAEh8KG1RSQUpFQ1RPUllfTUFUQ0hfVFlQRV9FWEFDVBABEiIKHlRSQUpFQ1RPUllfTUFUQ0hfVFlQRV9JTl9PUkRFUhACEiMKH1RSQUpFQ1RPUllfTUFUQ0hfVFlQRV9BTllfT1JERVIQAyqXAQoKRXZhbFN0YXR1cxIbChdFVkFMX1NUQVRVU19VTlNQRUNJRklFRBAAEhYKEkVWQUxfU1RBVFVTX1BBU1NFRBABEhYKEkVWQUxfU1RBVFVTX0ZBSUxFRBACEh0KGUVWQUxfU1RBVFVTX05PVF9FVkFMVUFURUQQAxIdChlFVkFMX1NUQVRVU19JTkZPUk1BVElPTkFMEAQy8gEKEUV2YWx1YXRpb25TZXJ2aWNlEmgKC0xpc3RNZXRyaWNzEisuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuTGlzdE1ldHJpY3NSZXF1ZXN0GiwuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuTGlzdE1ldHJpY3NSZXNwb25zZRJzCg5FdmFsdWF0ZVRyYWNlcxIuLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkV2YWx1YXRlVHJhY2VzUmVxdWVzdBovLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkV2YWx1YXRlVHJhY2VzUmVzcG9uc2UwAUJRWk9naXRodWIuY29tL2FnZW50ZXZhbHMtZGV2L2FnZW50ZXZhbHMvZ2VuL2dvL2FnZW50ZXZhbHMvYXBpL3YxYWxwaGExO2FwaXYxYWxwaGExYgZwcm90bzM", [file_agentevals_api_v1alpha1_common, file_google_protobuf_struct]); /** * Describes the message agentevals.api.v1alpha1.ListMetricsRequest. diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index d4fb473..3514b96 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -150,6 +150,9 @@ export interface PerformanceMetrics { export interface TurnRef { traceId: string; spanId: string; + /** The span the turn's evaluation results attach to. */ + evaluatedTraceId: string; + evaluatedSpanId: string; } export interface TraceResult { From 1c57347427d7e10b18a7492f3aa2b00665a92bb1 Mon Sep 17 00:00:00 2001 From: krisztianfekete Date: Fri, 13 Nov 2026 15:59:12 +0100 Subject: [PATCH 3/6] feat(api): stamp a run id on EvaluateTraces result records --- docs/grpc-api.md | 2 + docs/opentelemetry-pipeline.md | 3 +- .../agentevals/api/v1alpha1/evaluation.pb.go | 55 +++++++++++--- .../agentevals/api/v1alpha1/evaluation.proto | 4 ++ src/agentevals/api/evaluation.py | 12 +++- src/agentevals/api/routes.py | 8 ++- src/agentevals/api/rpc/services.py | 7 +- src/agentevals/api/v1alpha1/evaluation_pb2.py | 32 ++++----- .../api/v1alpha1/evaluation_pb2.pyi | 6 +- src/agentevals/run/service.py | 5 +- src/agentevals/runner.py | 2 + tests/api/test_evaluate_persistence.py | 18 +++++ tests/api/test_grpc_api.py | 72 +++++++++++++++++++ .../api/v1alpha1/evaluation_pb.d.ts | 9 +++ .../agentevals/api/v1alpha1/evaluation_pb.js | 2 +- 15 files changed, 200 insertions(+), 37 deletions(-) diff --git a/docs/grpc-api.md b/docs/grpc-api.md index 4913105..1d79269 100644 --- a/docs/grpc-api.md +++ b/docs/grpc-api.md @@ -22,6 +22,8 @@ Traces go in as OTLP protobuf (`TracesData` and `LogsData`) or as files (OTLP/JS `EvaluateTraces` lists the judged turns of each group in `turn_refs`. `evaluated_trace_id` and `evaluated_span_id` name the span the turn's results belong to, the same span agentevals attaches its own [evaluation result records](opentelemetry-pipeline.md#send-evaluation-results-from-agentevals) to. Use it when you attach results to telemetry yourself. +`EvaluateTraces` takes an optional `run_id` (a UUID). Every result record of the call carries it as `agentevals.eval.run.id`, and with run storage the evaluation is recorded under it. An id that is already recorded is rejected with `ALREADY_EXISTS`. Without `run_id`, a call recorded in run storage stamps its records with the recorded run's id. + ## Calling it With reflection on, `grpcurl` can list and call everything: diff --git a/docs/opentelemetry-pipeline.md b/docs/opentelemetry-pipeline.md index 01a57e0..daf9e37 100644 --- a/docs/opentelemetry-pipeline.md +++ b/docs/opentelemetry-pipeline.md @@ -169,6 +169,7 @@ Each result is a `gen_ai.evaluation.result` log event attached to the span it sc | `gen_ai.evaluation.explanation` | Only with `AGENTEVALS_EVALUATION_EVENTS_EXPLANATION=true` | | `gen_ai.agent.name`, `gen_ai.conversation.id`, `gen_ai.response.id` | Copied from the evaluated span when present | | `agentevals.evaluator.type` | `llm_judge` or `deterministic` for built in metrics | -| `agentevals.eval.run.id`, `agentevals.eval.case.id`, `agentevals.eval_set.id` | When known | +| `agentevals.eval.run.id` | The run: a queued run, an evaluation recorded in run storage, or the `run_id` of an `EvaluateTraces` call | +| `agentevals.eval.case.id`, `agentevals.eval_set.id` | When known | Events never contain prompts, responses or tool arguments. The explanation can, which is why it is off by default. diff --git a/gen/go/agentevals/api/v1alpha1/evaluation.pb.go b/gen/go/agentevals/api/v1alpha1/evaluation.pb.go index ee0d8d2..87c5c2e 100644 --- a/gen/go/agentevals/api/v1alpha1/evaluation.pb.go +++ b/gen/go/agentevals/api/v1alpha1/evaluation.pb.go @@ -981,11 +981,16 @@ func (x *MetricResult) GetDurationMs() float64 { } type TurnRef struct { - state protoimpl.MessageState `protogen:"open.v1"` - TraceId string `protobuf:"bytes,1,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` - SpanId string `protobuf:"bytes,2,opt,name=span_id,json=spanId,proto3" json:"span_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // The turn's anchor: its agent or workflow span, else the root span of its trace. + TraceId string `protobuf:"bytes,1,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + SpanId string `protobuf:"bytes,2,opt,name=span_id,json=spanId,proto3" json:"span_id,omitempty"` + // The span the turn's gen_ai.evaluation.result records attach to: the anchor, or the turn's + // final model call when the anchor is not a GenAI span. Attach results here. + EvaluatedTraceId string `protobuf:"bytes,3,opt,name=evaluated_trace_id,json=evaluatedTraceId,proto3" json:"evaluated_trace_id,omitempty"` + EvaluatedSpanId string `protobuf:"bytes,4,opt,name=evaluated_span_id,json=evaluatedSpanId,proto3" json:"evaluated_span_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *TurnRef) Reset() { @@ -1032,6 +1037,20 @@ func (x *TurnRef) GetSpanId() string { return "" } +func (x *TurnRef) GetEvaluatedTraceId() string { + if x != nil { + return x.EvaluatedTraceId + } + return "" +} + +func (x *TurnRef) GetEvaluatedSpanId() string { + if x != nil { + return x.EvaluatedSpanId + } + return "" +} + // The results of one evaluation group: a trace, or a conversation of several traces. type TraceResult struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1263,8 +1282,12 @@ type EvaluateTracesRequest struct { // {"kind": "kubernetes", "namespace": "...", "name": "...", "key": "..."} or // {"kind": "env", "name": "..."}. Values are resolved per request and never returned. CredentialRefs map[string]*structpb.Struct `protobuf:"bytes,4,rep,name=credential_refs,json=credentialRefs,proto3" json:"credential_refs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional. A UUID stamped as agentevals.eval.run.id on every gen_ai.evaluation.result record of + // this evaluation. With run storage the evaluation is recorded under this id, and an id that is + // already recorded is rejected; without it, storage picks the id and the records carry that. + RunId string `protobuf:"bytes,5,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EvaluateTracesRequest) Reset() { @@ -1334,6 +1357,13 @@ func (x *EvaluateTracesRequest) GetCredentialRefs() map[string]*structpb.Struct return nil } +func (x *EvaluateTracesRequest) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + type isEvaluateTracesRequest_EvalSet interface { isEvaluateTracesRequest_EvalSet() } @@ -1656,10 +1686,12 @@ const file_agentevals_api_v1alpha1_evaluation_proto_rawDesc = "" + "\x06_scoreB\b\n" + "\x06_errorB\r\n" + "\v_error_typeB\x0e\n" + - "\f_duration_ms\"=\n" + + "\f_duration_ms\"\x97\x01\n" + "\aTurnRef\x12\x19\n" + "\btrace_id\x18\x01 \x01(\tR\atraceId\x12\x17\n" + - "\aspan_id\x18\x02 \x01(\tR\x06spanId\"\xc8\x04\n" + + "\aspan_id\x18\x02 \x01(\tR\x06spanId\x12,\n" + + "\x12evaluated_trace_id\x18\x03 \x01(\tR\x10evaluatedTraceId\x12*\n" + + "\x11evaluated_span_id\x18\x04 \x01(\tR\x0fevaluatedSpanId\"\xc8\x04\n" + "\vTraceResult\x12\x19\n" + "\btrace_id\x18\x01 \x01(\tR\atraceId\x12'\n" + "\x0fnum_invocations\x18\x02 \x01(\x05R\x0enumInvocations\x12L\n" + @@ -1681,13 +1713,14 @@ const file_agentevals_api_v1alpha1_evaluation_proto_rawDesc = "" + "\rtrace_results\x18\x01 \x03(\v2$.agentevals.api.v1alpha1.TraceResultR\ftraceResults\x12\x16\n" + "\x06errors\x18\x02 \x03(\tR\x06errors\x12H\n" + "\x13performance_metrics\x18\x03 \x01(\v2\x17.google.protobuf.StructR\x12performanceMetrics\x12\x15\n" + - "\x06run_id\x18\x04 \x01(\tR\x05runId\"\xa3\x03\n" + + "\x06run_id\x18\x04 \x01(\tR\x05runId\"\xba\x03\n" + "\x15EvaluateTracesRequest\x12;\n" + "\x06traces\x18\x01 \x01(\v2#.agentevals.api.v1alpha1.TraceInputR\x06traces\x12;\n" + "\fadk_eval_set\x18\x02 \x01(\v2\x17.google.protobuf.StructH\x00R\n" + "adkEvalSet\x12;\n" + "\x06params\x18\x03 \x01(\v2#.agentevals.api.v1alpha1.EvalParamsR\x06params\x12k\n" + - "\x0fcredential_refs\x18\x04 \x03(\v2B.agentevals.api.v1alpha1.EvaluateTracesRequest.CredentialRefsEntryR\x0ecredentialRefs\x1aZ\n" + + "\x0fcredential_refs\x18\x04 \x03(\v2B.agentevals.api.v1alpha1.EvaluateTracesRequest.CredentialRefsEntryR\x0ecredentialRefs\x12\x15\n" + + "\x06run_id\x18\x05 \x01(\tR\x05runId\x1aZ\n" + "\x13CredentialRefsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12-\n" + "\x05value\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x05value:\x028\x01B\n" + diff --git a/proto/agentevals/api/v1alpha1/evaluation.proto b/proto/agentevals/api/v1alpha1/evaluation.proto index d614a02..33cc186 100644 --- a/proto/agentevals/api/v1alpha1/evaluation.proto +++ b/proto/agentevals/api/v1alpha1/evaluation.proto @@ -199,6 +199,10 @@ message EvaluateTracesRequest { // {"kind": "kubernetes", "namespace": "...", "name": "...", "key": "..."} or // {"kind": "env", "name": "..."}. Values are resolved per request and never returned. map credential_refs = 4; + // Optional. A UUID stamped as agentevals.eval.run.id on every gen_ai.evaluation.result record of + // this evaluation. With run storage the evaluation is recorded under this id, and an id that is + // already recorded is rejected; without it, storage picks the id and the records carry that. + string run_id = 5; } message EvaluationProgress { diff --git a/src/agentevals/api/evaluation.py b/src/agentevals/api/evaluation.py index b28fc20..a41c2ab 100644 --- a/src/agentevals/api/evaluation.py +++ b/src/agentevals/api/evaluation.py @@ -11,8 +11,9 @@ import contextlib import logging from collections.abc import AsyncIterator -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 from google.adk.evaluation.eval_set import EvalSet @@ -73,6 +74,8 @@ class Evaluation: eval_set_dict: dict[str, Any] | None = None trace_format: str | None = None upload_filenames: list[str] | None = None + # Stamped on the evaluation result records, and the recorded run's id. + run_id: str | None = None def performance_events(traces: list[Trace], group_by: str, eval_set: EvalSet | None) -> list[Performance]: @@ -99,7 +102,12 @@ async def stream_evaluation( Raises :class:`CredentialResolutionError` after the performance events when a credential reference cannot be resolved; nothing is evaluated then. + + With ``run_service``, the run id is chosen before evaluating, so the records emitted during the + evaluation carry the id the run is recorded under. """ + if run_service is not None and evaluation.run_id is None: + evaluation = replace(evaluation, run_id=str(uuid4())) for event in performance_events(evaluation.traces, evaluation.params.group_by, evaluation.eval_set): yield event resolved = await _resolve(evaluation.credential_refs) @@ -122,6 +130,7 @@ async def run() -> None: eval_set=evaluation.eval_set, progress_callback=on_progress, trace_progress_callback=on_group, + run_id=evaluation.run_id, ) else: result = RunResult(errors=["No traces loaded."]) @@ -181,6 +190,7 @@ async def persist_evaluation( return None try: run = await run_service.record_eval_run( + run_id=UUID(evaluation.run_id) if evaluation.run_id else None, params=evaluation.params, eval_set_dict=evaluation.eval_set_dict, trace_format=evaluation.trace_format, diff --git a/src/agentevals/api/routes.py b/src/agentevals/api/routes.py index 2eee121..254ecab 100644 --- a/src/agentevals/api/routes.py +++ b/src/agentevals/api/routes.py @@ -12,6 +12,7 @@ from collections.abc import AsyncIterator from contextlib import contextmanager from typing import Any +from uuid import uuid4 from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi.responses import StreamingResponse @@ -490,18 +491,21 @@ async def evaluate_traces( len(trace_paths), [e.name for e in eval_config.evaluators], ) + run_service = _run_service(request) + run_id = str(uuid4()) if run_service is not None else None resolved_creds = await _resolve_credentials(cred_refs) with _scoped_credentials(resolved_creds): - result = await run_evaluation(eval_config) + result = await run_evaluation(eval_config, run_id=run_id) run_id = await persist_evaluation( - _run_service(request), + run_service, Evaluation( traces=[], params=eval_config, eval_set_dict=_load_eval_set_dict(eval_set_path), trace_format=eval_config.trace_format, upload_filenames=[tf.filename for tf in trace_files if tf.filename], + run_id=run_id, ), result, ) diff --git a/src/agentevals/api/rpc/services.py b/src/agentevals/api/rpc/services.py index 5df7492..7ad1365 100644 --- a/src/agentevals/api/rpc/services.py +++ b/src/agentevals/api/rpc/services.py @@ -182,6 +182,10 @@ async def ListMetrics(self, request, context): # noqa: N802 @rpc() async def EvaluateTraces(self, request, context): # noqa: N802 + run_id = _uuid(request.run_id, "run_id") if request.run_id else None + run_service = self._context.run_service + if run_id is not None and run_service is not None and await run_service.get(run_id) is not None: + raise ApiError(grpc.StatusCode.ALREADY_EXISTS, "run_id already exists") traces, load_errors = await asyncio.to_thread(convert.load_trace_input, request.traces) eval_set_dict = convert.from_struct(request.adk_eval_set) if request.HasField("adk_eval_set") else None evaluation = Evaluation( @@ -191,9 +195,10 @@ async def EvaluateTraces(self, request, context): # noqa: N802 credential_refs={name: convert.from_struct(ref) for name, ref in request.credential_refs.items()} or None, load_errors=load_errors, eval_set_dict=eval_set_dict, + run_id=str(run_id) if run_id else None, ) try: - async for event in stream_evaluation(evaluation, run_service=self._context.run_service): + async for event in stream_evaluation(evaluation, run_service=run_service): yield _evaluation_response(event) except CredentialResolutionError as exc: logger.info("EvaluateTraces: %s", exc) diff --git a/src/agentevals/api/v1alpha1/evaluation_pb2.py b/src/agentevals/api/v1alpha1/evaluation_pb2.py index 358aadd..e4c189f 100644 --- a/src/agentevals/api/v1alpha1/evaluation_pb2.py +++ b/src/agentevals/api/v1alpha1/evaluation_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(agentevals/api/v1alpha1/evaluation.proto\x12\x17\x61gentevals.api.v1alpha1\x1a$agentevals/api/v1alpha1/common.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x14\n\x12ListMetricsRequest\"\x91\x02\n\x06Metric\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n\x08\x63\x61tegory\x18\x02 \x01(\tR\x08\x63\x61tegory\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12*\n\x11requires_eval_set\x18\x04 \x01(\x08R\x0frequiresEvalSet\x12!\n\x0crequires_llm\x18\x05 \x01(\x08R\x0brequiresLlm\x12!\n\x0crequires_gcp\x18\x06 \x01(\x08R\x0brequiresGcp\x12)\n\x10requires_rubrics\x18\x07 \x01(\x08R\x0frequiresRubrics\x12\x18\n\x07working\x18\x08 \x01(\x08R\x07working\"P\n\x13ListMetricsResponse\x12\x39\n\x07metrics\x18\x01 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.MetricR\x07metrics\"\xe2\x02\n\x10\x42uiltinEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1f\n\x0bjudge_model\x18\x03 \x01(\tR\njudgeModel\x12`\n\x15trajectory_match_type\x18\x04 \x01(\x0e\x32,.agentevals.api.v1alpha1.TrajectoryMatchTypeR\x13trajectoryMatchType\x12%\n\x0e\x63redential_ref\x18\x05 \x01(\tR\rcredentialRef\x12$\n\x0ejudge_base_url\x18\x06 \x01(\tR\x0cjudgeBaseUrl\x12\x39\n\x07rubrics\x18\x07 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.RubricR\x07rubricsB\x0c\n\n_threshold\",\n\x06Rubric\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04text\x18\x02 \x01(\tR\x04text\"\xe0\x01\n\rCodeEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x12\n\x04path\x18\x06 \x01(\tR\x04pathB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xf8\x01\n\x0fRemoteEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x16\n\x06source\x18\x06 \x01(\tR\x06source\x12\x10\n\x03ref\x18\x07 \x01(\tR\x03refB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xdc\x01\n\tEvaluator\x12\x45\n\x07\x62uiltin\x18\x01 \x01(\x0b\x32).agentevals.api.v1alpha1.BuiltinEvaluatorH\x00R\x07\x62uiltin\x12<\n\x04\x63ode\x18\x02 \x01(\x0b\x32&.agentevals.api.v1alpha1.CodeEvaluatorH\x00R\x04\x63ode\x12\x42\n\x06remote\x18\x03 \x01(\x0b\x32(.agentevals.api.v1alpha1.RemoteEvaluatorH\x00R\x06remoteB\x06\n\x04kind\"\xb0\x02\n\nEvalParams\x12\x42\n\nevaluators\x18\x01 \x03(\x0b\x32\".agentevals.api.v1alpha1.EvaluatorR\nevaluators\x12\x37\n\x15max_concurrent_traces\x18\x02 \x01(\x05H\x00R\x13maxConcurrentTraces\x88\x01\x01\x12\x35\n\x14max_concurrent_evals\x18\x03 \x01(\x05H\x01R\x12maxConcurrentEvals\x88\x01\x01\x12;\n\x08group_by\x18\x04 \x01(\x0e\x32 .agentevals.api.v1alpha1.GroupByR\x07groupByB\x18\n\x16_max_concurrent_tracesB\x17\n\x15_max_concurrent_evals\"t\n\x10InvocationResult\x12\x19\n\x05score\x18\x01 \x01(\x01H\x00R\x05score\x88\x01\x01\x12;\n\x06status\x18\x02 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\x06statusB\x08\n\x06_score\"\xad\x03\n\x0cMetricResult\x12\x1f\n\x0bmetric_name\x18\x01 \x01(\tR\nmetricName\x12\x19\n\x05score\x18\x02 \x01(\x01H\x00R\x05score\x88\x01\x01\x12\x44\n\x0b\x65val_status\x18\x03 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\nevalStatus\x12P\n\x0eper_invocation\x18\x04 \x03(\x0b\x32).agentevals.api.v1alpha1.InvocationResultR\rperInvocation\x12\x19\n\x05\x65rror\x18\x05 \x01(\tH\x01R\x05\x65rror\x88\x01\x01\x12\"\n\nerror_type\x18\x06 \x01(\tH\x02R\terrorType\x88\x01\x01\x12\x31\n\x07\x64\x65tails\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructR\x07\x64\x65tails\x12$\n\x0b\x64uration_ms\x18\x08 \x01(\x01H\x03R\ndurationMs\x88\x01\x01\x42\x08\n\x06_scoreB\x08\n\x06_errorB\r\n\x0b_error_typeB\x0e\n\x0c_duration_ms\"\x97\x01\n\x07TurnRef\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\x17\n\x07span_id\x18\x02 \x01(\tR\x06spanId\x12,\n\x12\x65valuated_trace_id\x18\x03 \x01(\tR\x10\x65valuatedTraceId\x12*\n\x11\x65valuated_span_id\x18\x04 \x01(\tR\x0f\x65valuatedSpanId\"\xc8\x04\n\x0bTraceResult\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\'\n\x0fnum_invocations\x18\x02 \x01(\x05R\x0enumInvocations\x12L\n\x0emetric_results\x18\x03 \x03(\x0b\x32%.agentevals.api.v1alpha1.MetricResultR\rmetricResults\x12/\n\x13\x63onversion_warnings\x18\x04 \x03(\tR\x12\x63onversionWarnings\x12H\n\x13performance_metrics\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12!\n\x0cservice_name\x18\x06 \x01(\tR\x0bserviceName\x12\x1d\n\nagent_name\x18\x07 \x01(\tR\tagentName\x12\x1b\n\ttrace_ids\x18\x08 \x03(\tR\x08traceIds\x12\'\n\x0f\x63onversation_id\x18\t \x01(\tR\x0e\x63onversationId\x12\x1b\n\tgroup_key\x18\n \x01(\tR\x08groupKey\x12 \n\x0c\x65val_case_id\x18\x0b \x01(\tR\nevalCaseId\x12&\n\x0f\x65val_case_match\x18\x0c \x01(\tR\revalCaseMatch\x12=\n\tturn_refs\x18\r \x03(\x0b\x32 .agentevals.api.v1alpha1.TurnRefR\x08turnRefs\"\xcf\x01\n\tRunResult\x12I\n\rtrace_results\x18\x01 \x03(\x0b\x32$.agentevals.api.v1alpha1.TraceResultR\x0ctraceResults\x12\x16\n\x06\x65rrors\x18\x02 \x03(\tR\x06\x65rrors\x12H\n\x13performance_metrics\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12\x15\n\x06run_id\x18\x04 \x01(\tR\x05runId\"\xa3\x03\n\x15\x45valuateTracesRequest\x12;\n\x06traces\x18\x01 \x01(\x0b\x32#.agentevals.api.v1alpha1.TraceInputR\x06traces\x12;\n\x0c\x61\x64k_eval_set\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructH\x00R\nadkEvalSet\x12;\n\x06params\x18\x03 \x01(\x0b\x32#.agentevals.api.v1alpha1.EvalParamsR\x06params\x12k\n\x0f\x63redential_refs\x18\x04 \x03(\x0b\x32\x42.agentevals.api.v1alpha1.EvaluateTracesRequest.CredentialRefsEntryR\x0e\x63redentialRefs\x1aZ\n\x13\x43redentialRefsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12-\n\x05value\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x05value:\x02\x38\x01\x42\n\n\x08\x65val_set\".\n\x12\x45valuationProgress\x12\x18\n\x07message\x18\x01 \x01(\tR\x07message\"\xb7\x01\n\x10GroupPerformance\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12H\n\x13performance_metrics\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12>\n\x0etrace_metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\rtraceMetadata\"\xc0\x02\n\x16\x45valuateTracesResponse\x12I\n\x08progress\x18\x01 \x01(\x0b\x32+.agentevals.api.v1alpha1.EvaluationProgressH\x00R\x08progress\x12M\n\x0bperformance\x18\x02 \x01(\x0b\x32).agentevals.api.v1alpha1.GroupPerformanceH\x00R\x0bperformance\x12I\n\x0ctrace_result\x18\x03 \x01(\x0b\x32$.agentevals.api.v1alpha1.TraceResultH\x00R\x0btraceResult\x12\x38\n\x04\x64one\x18\x04 \x01(\x0b\x32\".agentevals.api.v1alpha1.RunResultH\x00R\x04\x64oneB\x07\n\x05\x65vent*\xa6\x01\n\x13TrajectoryMatchType\x12%\n!TRAJECTORY_MATCH_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n\x1bTRAJECTORY_MATCH_TYPE_EXACT\x10\x01\x12\"\n\x1eTRAJECTORY_MATCH_TYPE_IN_ORDER\x10\x02\x12#\n\x1fTRAJECTORY_MATCH_TYPE_ANY_ORDER\x10\x03*\x97\x01\n\nEvalStatus\x12\x1b\n\x17\x45VAL_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12\x45VAL_STATUS_PASSED\x10\x01\x12\x16\n\x12\x45VAL_STATUS_FAILED\x10\x02\x12\x1d\n\x19\x45VAL_STATUS_NOT_EVALUATED\x10\x03\x12\x1d\n\x19\x45VAL_STATUS_INFORMATIONAL\x10\x04\x32\xf2\x01\n\x11\x45valuationService\x12h\n\x0bListMetrics\x12+.agentevals.api.v1alpha1.ListMetricsRequest\x1a,.agentevals.api.v1alpha1.ListMetricsResponse\x12s\n\x0e\x45valuateTraces\x12..agentevals.api.v1alpha1.EvaluateTracesRequest\x1a/.agentevals.api.v1alpha1.EvaluateTracesResponse0\x01\x42QZOgithub.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1;apiv1alpha1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(agentevals/api/v1alpha1/evaluation.proto\x12\x17\x61gentevals.api.v1alpha1\x1a$agentevals/api/v1alpha1/common.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x14\n\x12ListMetricsRequest\"\x91\x02\n\x06Metric\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x1a\n\x08\x63\x61tegory\x18\x02 \x01(\tR\x08\x63\x61tegory\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12*\n\x11requires_eval_set\x18\x04 \x01(\x08R\x0frequiresEvalSet\x12!\n\x0crequires_llm\x18\x05 \x01(\x08R\x0brequiresLlm\x12!\n\x0crequires_gcp\x18\x06 \x01(\x08R\x0brequiresGcp\x12)\n\x10requires_rubrics\x18\x07 \x01(\x08R\x0frequiresRubrics\x12\x18\n\x07working\x18\x08 \x01(\x08R\x07working\"P\n\x13ListMetricsResponse\x12\x39\n\x07metrics\x18\x01 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.MetricR\x07metrics\"\xe2\x02\n\x10\x42uiltinEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1f\n\x0bjudge_model\x18\x03 \x01(\tR\njudgeModel\x12`\n\x15trajectory_match_type\x18\x04 \x01(\x0e\x32,.agentevals.api.v1alpha1.TrajectoryMatchTypeR\x13trajectoryMatchType\x12%\n\x0e\x63redential_ref\x18\x05 \x01(\tR\rcredentialRef\x12$\n\x0ejudge_base_url\x18\x06 \x01(\tR\x0cjudgeBaseUrl\x12\x39\n\x07rubrics\x18\x07 \x03(\x0b\x32\x1f.agentevals.api.v1alpha1.RubricR\x07rubricsB\x0c\n\n_threshold\",\n\x06Rubric\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n\x04text\x18\x02 \x01(\tR\x04text\"\xe0\x01\n\rCodeEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x12\n\x04path\x18\x06 \x01(\tR\x04pathB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xf8\x01\n\x0fRemoteEvaluator\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12!\n\tthreshold\x18\x02 \x01(\x01H\x00R\tthreshold\x88\x01\x01\x12\x1d\n\x07timeout\x18\x03 \x01(\x05H\x01R\x07timeout\x88\x01\x01\x12/\n\x06\x63onfig\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x06\x63onfig\x12\x1a\n\x08\x65xecutor\x18\x05 \x01(\tR\x08\x65xecutor\x12\x16\n\x06source\x18\x06 \x01(\tR\x06source\x12\x10\n\x03ref\x18\x07 \x01(\tR\x03refB\x0c\n\n_thresholdB\n\n\x08_timeout\"\xdc\x01\n\tEvaluator\x12\x45\n\x07\x62uiltin\x18\x01 \x01(\x0b\x32).agentevals.api.v1alpha1.BuiltinEvaluatorH\x00R\x07\x62uiltin\x12<\n\x04\x63ode\x18\x02 \x01(\x0b\x32&.agentevals.api.v1alpha1.CodeEvaluatorH\x00R\x04\x63ode\x12\x42\n\x06remote\x18\x03 \x01(\x0b\x32(.agentevals.api.v1alpha1.RemoteEvaluatorH\x00R\x06remoteB\x06\n\x04kind\"\xb0\x02\n\nEvalParams\x12\x42\n\nevaluators\x18\x01 \x03(\x0b\x32\".agentevals.api.v1alpha1.EvaluatorR\nevaluators\x12\x37\n\x15max_concurrent_traces\x18\x02 \x01(\x05H\x00R\x13maxConcurrentTraces\x88\x01\x01\x12\x35\n\x14max_concurrent_evals\x18\x03 \x01(\x05H\x01R\x12maxConcurrentEvals\x88\x01\x01\x12;\n\x08group_by\x18\x04 \x01(\x0e\x32 .agentevals.api.v1alpha1.GroupByR\x07groupByB\x18\n\x16_max_concurrent_tracesB\x17\n\x15_max_concurrent_evals\"t\n\x10InvocationResult\x12\x19\n\x05score\x18\x01 \x01(\x01H\x00R\x05score\x88\x01\x01\x12;\n\x06status\x18\x02 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\x06statusB\x08\n\x06_score\"\xad\x03\n\x0cMetricResult\x12\x1f\n\x0bmetric_name\x18\x01 \x01(\tR\nmetricName\x12\x19\n\x05score\x18\x02 \x01(\x01H\x00R\x05score\x88\x01\x01\x12\x44\n\x0b\x65val_status\x18\x03 \x01(\x0e\x32#.agentevals.api.v1alpha1.EvalStatusR\nevalStatus\x12P\n\x0eper_invocation\x18\x04 \x03(\x0b\x32).agentevals.api.v1alpha1.InvocationResultR\rperInvocation\x12\x19\n\x05\x65rror\x18\x05 \x01(\tH\x01R\x05\x65rror\x88\x01\x01\x12\"\n\nerror_type\x18\x06 \x01(\tH\x02R\terrorType\x88\x01\x01\x12\x31\n\x07\x64\x65tails\x18\x07 \x01(\x0b\x32\x17.google.protobuf.StructR\x07\x64\x65tails\x12$\n\x0b\x64uration_ms\x18\x08 \x01(\x01H\x03R\ndurationMs\x88\x01\x01\x42\x08\n\x06_scoreB\x08\n\x06_errorB\r\n\x0b_error_typeB\x0e\n\x0c_duration_ms\"\x97\x01\n\x07TurnRef\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\x17\n\x07span_id\x18\x02 \x01(\tR\x06spanId\x12,\n\x12\x65valuated_trace_id\x18\x03 \x01(\tR\x10\x65valuatedTraceId\x12*\n\x11\x65valuated_span_id\x18\x04 \x01(\tR\x0f\x65valuatedSpanId\"\xc8\x04\n\x0bTraceResult\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12\'\n\x0fnum_invocations\x18\x02 \x01(\x05R\x0enumInvocations\x12L\n\x0emetric_results\x18\x03 \x03(\x0b\x32%.agentevals.api.v1alpha1.MetricResultR\rmetricResults\x12/\n\x13\x63onversion_warnings\x18\x04 \x03(\tR\x12\x63onversionWarnings\x12H\n\x13performance_metrics\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12!\n\x0cservice_name\x18\x06 \x01(\tR\x0bserviceName\x12\x1d\n\nagent_name\x18\x07 \x01(\tR\tagentName\x12\x1b\n\ttrace_ids\x18\x08 \x03(\tR\x08traceIds\x12\'\n\x0f\x63onversation_id\x18\t \x01(\tR\x0e\x63onversationId\x12\x1b\n\tgroup_key\x18\n \x01(\tR\x08groupKey\x12 \n\x0c\x65val_case_id\x18\x0b \x01(\tR\nevalCaseId\x12&\n\x0f\x65val_case_match\x18\x0c \x01(\tR\revalCaseMatch\x12=\n\tturn_refs\x18\r \x03(\x0b\x32 .agentevals.api.v1alpha1.TurnRefR\x08turnRefs\"\xcf\x01\n\tRunResult\x12I\n\rtrace_results\x18\x01 \x03(\x0b\x32$.agentevals.api.v1alpha1.TraceResultR\x0ctraceResults\x12\x16\n\x06\x65rrors\x18\x02 \x03(\tR\x06\x65rrors\x12H\n\x13performance_metrics\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12\x15\n\x06run_id\x18\x04 \x01(\tR\x05runId\"\xba\x03\n\x15\x45valuateTracesRequest\x12;\n\x06traces\x18\x01 \x01(\x0b\x32#.agentevals.api.v1alpha1.TraceInputR\x06traces\x12;\n\x0c\x61\x64k_eval_set\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructH\x00R\nadkEvalSet\x12;\n\x06params\x18\x03 \x01(\x0b\x32#.agentevals.api.v1alpha1.EvalParamsR\x06params\x12k\n\x0f\x63redential_refs\x18\x04 \x03(\x0b\x32\x42.agentevals.api.v1alpha1.EvaluateTracesRequest.CredentialRefsEntryR\x0e\x63redentialRefs\x12\x15\n\x06run_id\x18\x05 \x01(\tR\x05runId\x1aZ\n\x13\x43redentialRefsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12-\n\x05value\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x05value:\x02\x38\x01\x42\n\n\x08\x65val_set\".\n\x12\x45valuationProgress\x12\x18\n\x07message\x18\x01 \x01(\tR\x07message\"\xb7\x01\n\x10GroupPerformance\x12\x19\n\x08trace_id\x18\x01 \x01(\tR\x07traceId\x12H\n\x13performance_metrics\x18\x02 \x01(\x0b\x32\x17.google.protobuf.StructR\x12performanceMetrics\x12>\n\x0etrace_metadata\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructR\rtraceMetadata\"\xc0\x02\n\x16\x45valuateTracesResponse\x12I\n\x08progress\x18\x01 \x01(\x0b\x32+.agentevals.api.v1alpha1.EvaluationProgressH\x00R\x08progress\x12M\n\x0bperformance\x18\x02 \x01(\x0b\x32).agentevals.api.v1alpha1.GroupPerformanceH\x00R\x0bperformance\x12I\n\x0ctrace_result\x18\x03 \x01(\x0b\x32$.agentevals.api.v1alpha1.TraceResultH\x00R\x0btraceResult\x12\x38\n\x04\x64one\x18\x04 \x01(\x0b\x32\".agentevals.api.v1alpha1.RunResultH\x00R\x04\x64oneB\x07\n\x05\x65vent*\xa6\x01\n\x13TrajectoryMatchType\x12%\n!TRAJECTORY_MATCH_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n\x1bTRAJECTORY_MATCH_TYPE_EXACT\x10\x01\x12\"\n\x1eTRAJECTORY_MATCH_TYPE_IN_ORDER\x10\x02\x12#\n\x1fTRAJECTORY_MATCH_TYPE_ANY_ORDER\x10\x03*\x97\x01\n\nEvalStatus\x12\x1b\n\x17\x45VAL_STATUS_UNSPECIFIED\x10\x00\x12\x16\n\x12\x45VAL_STATUS_PASSED\x10\x01\x12\x16\n\x12\x45VAL_STATUS_FAILED\x10\x02\x12\x1d\n\x19\x45VAL_STATUS_NOT_EVALUATED\x10\x03\x12\x1d\n\x19\x45VAL_STATUS_INFORMATIONAL\x10\x04\x32\xf2\x01\n\x11\x45valuationService\x12h\n\x0bListMetrics\x12+.agentevals.api.v1alpha1.ListMetricsRequest\x1a,.agentevals.api.v1alpha1.ListMetricsResponse\x12s\n\x0e\x45valuateTraces\x12..agentevals.api.v1alpha1.EvaluateTracesRequest\x1a/.agentevals.api.v1alpha1.EvaluateTracesResponse0\x01\x42QZOgithub.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1;apiv1alpha1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -36,10 +36,10 @@ _globals['DESCRIPTOR']._serialized_options = b'ZOgithub.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1;apiv1alpha1' _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._loaded_options = None _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_options = b'8\001' - _globals['_TRAJECTORYMATCHTYPE']._serialized_start=4409 - _globals['_TRAJECTORYMATCHTYPE']._serialized_end=4575 - _globals['_EVALSTATUS']._serialized_start=4578 - _globals['_EVALSTATUS']._serialized_end=4729 + _globals['_TRAJECTORYMATCHTYPE']._serialized_start=4432 + _globals['_TRAJECTORYMATCHTYPE']._serialized_end=4598 + _globals['_EVALSTATUS']._serialized_start=4601 + _globals['_EVALSTATUS']._serialized_end=4752 _globals['_LISTMETRICSREQUEST']._serialized_start=137 _globals['_LISTMETRICSREQUEST']._serialized_end=157 _globals['_METRIC']._serialized_start=160 @@ -69,15 +69,15 @@ _globals['_RUNRESULT']._serialized_start=3220 _globals['_RUNRESULT']._serialized_end=3427 _globals['_EVALUATETRACESREQUEST']._serialized_start=3430 - _globals['_EVALUATETRACESREQUEST']._serialized_end=3849 - _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_start=3747 - _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_end=3837 - _globals['_EVALUATIONPROGRESS']._serialized_start=3851 - _globals['_EVALUATIONPROGRESS']._serialized_end=3897 - _globals['_GROUPPERFORMANCE']._serialized_start=3900 - _globals['_GROUPPERFORMANCE']._serialized_end=4083 - _globals['_EVALUATETRACESRESPONSE']._serialized_start=4086 - _globals['_EVALUATETRACESRESPONSE']._serialized_end=4406 - _globals['_EVALUATIONSERVICE']._serialized_start=4732 - _globals['_EVALUATIONSERVICE']._serialized_end=4974 + _globals['_EVALUATETRACESREQUEST']._serialized_end=3872 + _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_start=3770 + _globals['_EVALUATETRACESREQUEST_CREDENTIALREFSENTRY']._serialized_end=3860 + _globals['_EVALUATIONPROGRESS']._serialized_start=3874 + _globals['_EVALUATIONPROGRESS']._serialized_end=3920 + _globals['_GROUPPERFORMANCE']._serialized_start=3923 + _globals['_GROUPPERFORMANCE']._serialized_end=4106 + _globals['_EVALUATETRACESRESPONSE']._serialized_start=4109 + _globals['_EVALUATETRACESRESPONSE']._serialized_end=4429 + _globals['_EVALUATIONSERVICE']._serialized_start=4755 + _globals['_EVALUATIONSERVICE']._serialized_end=4997 # @@protoc_insertion_point(module_scope) diff --git a/src/agentevals/api/v1alpha1/evaluation_pb2.pyi b/src/agentevals/api/v1alpha1/evaluation_pb2.pyi index 02ec6cc..207c267 100644 --- a/src/agentevals/api/v1alpha1/evaluation_pb2.pyi +++ b/src/agentevals/api/v1alpha1/evaluation_pb2.pyi @@ -228,7 +228,7 @@ class RunResult(_message.Message): def __init__(self, trace_results: _Optional[_Iterable[_Union[TraceResult, _Mapping]]] = ..., errors: _Optional[_Iterable[str]] = ..., performance_metrics: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., run_id: _Optional[str] = ...) -> None: ... class EvaluateTracesRequest(_message.Message): - __slots__ = ("traces", "adk_eval_set", "params", "credential_refs") + __slots__ = ("traces", "adk_eval_set", "params", "credential_refs", "run_id") class CredentialRefsEntry(_message.Message): __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] @@ -240,11 +240,13 @@ class EvaluateTracesRequest(_message.Message): ADK_EVAL_SET_FIELD_NUMBER: _ClassVar[int] PARAMS_FIELD_NUMBER: _ClassVar[int] CREDENTIAL_REFS_FIELD_NUMBER: _ClassVar[int] + RUN_ID_FIELD_NUMBER: _ClassVar[int] traces: _common_pb2.TraceInput adk_eval_set: _struct_pb2.Struct params: EvalParams credential_refs: _containers.MessageMap[str, _struct_pb2.Struct] - def __init__(self, traces: _Optional[_Union[_common_pb2.TraceInput, _Mapping]] = ..., adk_eval_set: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., params: _Optional[_Union[EvalParams, _Mapping]] = ..., credential_refs: _Optional[_Mapping[str, _struct_pb2.Struct]] = ...) -> None: ... + run_id: str + def __init__(self, traces: _Optional[_Union[_common_pb2.TraceInput, _Mapping]] = ..., adk_eval_set: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., params: _Optional[_Union[EvalParams, _Mapping]] = ..., credential_refs: _Optional[_Mapping[str, _struct_pb2.Struct]] = ..., run_id: _Optional[str] = ...) -> None: ... class EvaluationProgress(_message.Message): __slots__ = ("message",) diff --git a/src/agentevals/run/service.py b/src/agentevals/run/service.py index a7fe3c7..f3eb0a2 100644 --- a/src/agentevals/run/service.py +++ b/src/agentevals/run/service.py @@ -90,9 +90,10 @@ async def record_eval_run( trace_format: str | None, upload_filenames: list[str] | None, run_result: RunResult, + run_id: UUID | None = None, ) -> Run: """Persist a synchronously-completed ``/api/evaluate`` call as a Run - row plus Result rows. + row plus Result rows, under ``run_id`` when given. Builds an ``uploaded`` :class:`TraceTarget` from the request metadata, creates a queued run, persists results, then transitions the run to @@ -114,7 +115,7 @@ async def record_eval_run( eval_set=eval_set_dict, ) - run_id = uuid4() + run_id = run_id or uuid4() run = Run( run_id=run_id, status=RunStatus.QUEUED, diff --git a/src/agentevals/runner.py b/src/agentevals/runner.py index ad49beb..41861d4 100644 --- a/src/agentevals/runner.py +++ b/src/agentevals/runner.py @@ -211,6 +211,7 @@ async def run_evaluation( config: EvalRunConfig, progress_callback: ProgressCallback | None = None, trace_progress_callback: TraceProgressCallback | None = None, + run_id: str | None = None, ) -> RunResult: """Load trace files, then evaluate them with :func:`run_evaluation_from_traces`.""" load_errors: list[str] = [] @@ -241,6 +242,7 @@ async def run_evaluation( eval_set=eval_set, progress_callback=progress_callback, trace_progress_callback=trace_progress_callback, + run_id=run_id, ) if load_errors: result.errors = load_errors + result.errors diff --git a/tests/api/test_evaluate_persistence.py b/tests/api/test_evaluate_persistence.py index df203e2..0e9333e 100644 --- a/tests/api/test_evaluate_persistence.py +++ b/tests/api/test_evaluate_persistence.py @@ -16,7 +16,9 @@ import pytest from fastapi.testclient import TestClient +from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter +from agentevals import evaluation_events as emit from agentevals.api.app import create_app from agentevals.config import EvalParams from agentevals.run.service import RunService @@ -82,6 +84,22 @@ def test_run_persisted_when_run_service_set(self, app_with_runs): assert run.spec.target.trace_files == ["helm.json"] assert run.spec.target.trace_count == 1 + def test_records_carry_the_recorded_run_id(self, app_with_runs, monkeypatch): + exporter = InMemoryLogRecordExporter() + emitter = emit.EvaluationEmitter(exporter) + monkeypatch.setattr(emit, "get_emitter", lambda: emitter) + app, _ = app_with_runs + with TestClient(app) as client: + with SAMPLE_TRACE.open("rb") as f: + r = client.post( + "/api/evaluate", + files={"trace_files": ("helm.json", f, "application/json")}, + data={"config": '{"evaluators": [{"name": "tool_trajectory_avg_score", "type": "builtin"}]}'}, + ) + emitter.flush() + stamped = {dict(r.log_record.attributes).get("agentevals.eval.run.id") for r in exporter.get_finished_logs()} + assert stamped == {r.json()["data"]["runId"]} + def test_recorded_run_keeps_no_server_paths(self, app_with_runs): app, repos = app_with_runs with TestClient(app) as client: diff --git a/tests/api/test_grpc_api.py b/tests/api/test_grpc_api.py index c623a50..d74ef4c 100644 --- a/tests/api/test_grpc_api.py +++ b/tests/api/test_grpc_api.py @@ -19,8 +19,10 @@ from grpc_health.v1 import health_pb2, health_pb2_grpc from grpc_reflection.v1alpha import reflection_pb2, reflection_pb2_grpc from opentelemetry.proto.trace.v1 import trace_pb2 +from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter from agentevals import __version__ +from agentevals import evaluation_events as emit from agentevals.api.app import create_app from agentevals.api.rpc import convert, services from agentevals.api.rpc.server import start_api_server, stop_api_server @@ -74,6 +76,20 @@ def context(repos, monkeypatch): return ApiContext(manager=LiveManager(), run_service=RunService(repos.runs, repos.results), ports=PORTS) +@pytest.fixture +def records(monkeypatch): + """The attributes of the evaluation result records emitted so far.""" + exporter = InMemoryLogRecordExporter() + emitter = emit.EvaluationEmitter(exporter) + monkeypatch.setattr(emit, "get_emitter", lambda: emitter) + + def finished() -> list[dict]: + emitter.flush() + return [dict(r.log_record.attributes) for r in exporter.get_finished_logs()] + + return finished + + @pytest.fixture async def channel(context): api = await start_api_server(context, "127.0.0.1", 0) @@ -438,6 +454,62 @@ async def test_unresolvable_credential_is_failed_precondition(self, channel): assert error.value.code() == grpc.StatusCode.FAILED_PRECONDITION assert MARK not in error.value.details() + async def test_records_carry_the_requested_run_id_and_the_run_is_recorded_under_it(self, channel, records, repos): + run_id = str(uuid4()) + request = evaluation_pb2.EvaluateTracesRequest( + traces=common_pb2.TraceInput(documents=[_document()]), + adk_eval_set=await _golden(channel), + params=_trajectory_params(), + run_id=run_id.upper(), + ) + events = await _stream(evaluation_pb2_grpc.EvaluationServiceStub(channel).EvaluateTraces(request)) + assert events[-1].done.run_id == run_id + assert await repos.runs.get(UUID(run_id)) is not None + stamped = [r.get("agentevals.eval.run.id") for r in records()] + assert stamped and set(stamped) == {run_id} + + async def test_without_a_run_id_records_carry_the_recorded_one(self, channel, records): + request = evaluation_pb2.EvaluateTracesRequest( + traces=common_pb2.TraceInput(documents=[_document()]), + adk_eval_set=await _golden(channel), + params=_trajectory_params(), + ) + events = await _stream(evaluation_pb2_grpc.EvaluationServiceStub(channel).EvaluateTraces(request)) + stamped = [r.get("agentevals.eval.run.id") for r in records()] + assert events[-1].done.run_id and set(stamped) == {events[-1].done.run_id} + + async def test_without_storage_the_requested_run_id_is_still_stamped(self, channel, context, records): + context.run_service = None + run_id = str(uuid4()) + request = evaluation_pb2.EvaluateTracesRequest( + traces=common_pb2.TraceInput(documents=[_document()]), + adk_eval_set=await _golden(channel), + params=_trajectory_params(), + run_id=run_id, + ) + events = await _stream(evaluation_pb2_grpc.EvaluationServiceStub(channel).EvaluateTraces(request)) + assert events[-1].done.run_id == "" + assert {r.get("agentevals.eval.run.id") for r in records()} == {run_id} + + async def test_a_run_id_that_is_not_a_uuid_is_invalid_argument(self, channel, records): + request = evaluation_pb2.EvaluateTracesRequest( + traces=common_pb2.TraceInput(documents=[_document()]), params=_trajectory_params(), run_id="run-1" + ) + with pytest.raises(grpc.aio.AioRpcError) as error: + await _stream(evaluation_pb2_grpc.EvaluationServiceStub(channel).EvaluateTraces(request)) + assert error.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert records() == [] + + async def test_a_recorded_run_id_is_already_exists(self, channel, records): + run = (await runs_pb2_grpc.RunServiceStub(channel).SubmitRun(runs_pb2.SubmitRunRequest(spec=_run_spec()))).run + request = evaluation_pb2.EvaluateTracesRequest( + traces=common_pb2.TraceInput(documents=[_document()]), params=_trajectory_params(), run_id=run.run_id + ) + with pytest.raises(grpc.aio.AioRpcError) as error: + await _stream(evaluation_pb2_grpc.EvaluationServiceStub(channel).EvaluateTraces(request)) + assert error.value.code() == grpc.StatusCode.ALREADY_EXISTS + assert records() == [] + async def test_evaluator_without_a_kind_is_invalid_argument(self, channel): request = evaluation_pb2.EvaluateTracesRequest( traces=common_pb2.TraceInput(documents=[_document()]), diff --git a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts index b058b9e..0753054 100644 --- a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts +++ b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.d.ts @@ -641,6 +641,15 @@ export declare type EvaluateTracesRequest = Message<"agentevals.api.v1alpha1.Eva * @generated from field: map credential_refs = 4; */ credentialRefs: { [key: string]: JsonObject }; + + /** + * Optional. A UUID stamped as agentevals.eval.run.id on every gen_ai.evaluation.result record of + * this evaluation. With run storage the evaluation is recorded under this id, and an id that is + * already recorded is rejected; without it, storage picks the id and the records carry that. + * + * @generated from field: string run_id = 5; + */ + runId: string; }; /** diff --git a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js index b52418b..0e0eb72 100644 --- a/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js +++ b/ui/src/generated/agentevals/api/v1alpha1/evaluation_pb.js @@ -10,7 +10,7 @@ import { file_google_protobuf_struct } from "@bufbuild/protobuf/wkt"; * Describes the file agentevals/api/v1alpha1/evaluation.proto. */ export const file_agentevals_api_v1alpha1_evaluation = /*@__PURE__*/ - fileDesc("CihhZ2VudGV2YWxzL2FwaS92MWFscGhhMS9ldmFsdWF0aW9uLnByb3RvEhdhZ2VudGV2YWxzLmFwaS52MWFscGhhMSIUChJMaXN0TWV0cmljc1JlcXVlc3QirwEKBk1ldHJpYxIMCgRuYW1lGAEgASgJEhAKCGNhdGVnb3J5GAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhkKEXJlcXVpcmVzX2V2YWxfc2V0GAQgASgIEhQKDHJlcXVpcmVzX2xsbRgFIAEoCBIUCgxyZXF1aXJlc19nY3AYBiABKAgSGAoQcmVxdWlyZXNfcnVicmljcxgHIAEoCBIPCgd3b3JraW5nGAggASgIIkcKE0xpc3RNZXRyaWNzUmVzcG9uc2USMAoHbWV0cmljcxgBIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLk1ldHJpYyKKAgoQQnVpbHRpbkV2YWx1YXRvchIMCgRuYW1lGAEgASgJEhYKCXRocmVzaG9sZBgCIAEoAUgAiAEBEhMKC2p1ZGdlX21vZGVsGAMgASgJEksKFXRyYWplY3RvcnlfbWF0Y2hfdHlwZRgEIAEoDjIsLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlRyYWplY3RvcnlNYXRjaFR5cGUSFgoOY3JlZGVudGlhbF9yZWYYBSABKAkSFgoOanVkZ2VfYmFzZV91cmwYBiABKAkSMAoHcnVicmljcxgHIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlJ1YnJpY0IMCgpfdGhyZXNob2xkIiIKBlJ1YnJpYxIKCgJpZBgBIAEoCRIMCgR0ZXh0GAIgASgJIq4BCg1Db2RlRXZhbHVhdG9yEgwKBG5hbWUYASABKAkSFgoJdGhyZXNob2xkGAIgASgBSACIAQESFAoHdGltZW91dBgDIAEoBUgBiAEBEicKBmNvbmZpZxgEIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSEAoIZXhlY3V0b3IYBSABKAkSDAoEcGF0aBgGIAEoCUIMCgpfdGhyZXNob2xkQgoKCF90aW1lb3V0Ir8BCg9SZW1vdGVFdmFsdWF0b3ISDAoEbmFtZRgBIAEoCRIWCgl0aHJlc2hvbGQYAiABKAFIAIgBARIUCgd0aW1lb3V0GAMgASgFSAGIAQESJwoGY29uZmlnGAQgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIQCghleGVjdXRvchgFIAEoCRIOCgZzb3VyY2UYBiABKAkSCwoDcmVmGAcgASgJQgwKCl90aHJlc2hvbGRCCgoIX3RpbWVvdXQixQEKCUV2YWx1YXRvchI8CgdidWlsdGluGAEgASgLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuQnVpbHRpbkV2YWx1YXRvckgAEjYKBGNvZGUYAiABKAsyJi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Db2RlRXZhbHVhdG9ySAASOgoGcmVtb3RlGAMgASgLMiguYWdlbnRldmFscy5hcGkudjFhbHBoYTEuUmVtb3RlRXZhbHVhdG9ySABCBgoEa2luZCLyAQoKRXZhbFBhcmFtcxI2CgpldmFsdWF0b3JzGAEgAygLMiIuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbHVhdG9yEiIKFW1heF9jb25jdXJyZW50X3RyYWNlcxgCIAEoBUgAiAEBEiEKFG1heF9jb25jdXJyZW50X2V2YWxzGAMgASgFSAGIAQESMgoIZ3JvdXBfYnkYBCABKA4yIC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Hcm91cEJ5QhgKFl9tYXhfY29uY3VycmVudF90cmFjZXNCFwoVX21heF9jb25jdXJyZW50X2V2YWxzImUKEEludm9jYXRpb25SZXN1bHQSEgoFc2NvcmUYASABKAFIAIgBARIzCgZzdGF0dXMYAiABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzQggKBl9zY29yZSLYAgoMTWV0cmljUmVzdWx0EhMKC21ldHJpY19uYW1lGAEgASgJEhIKBXNjb3JlGAIgASgBSACIAQESOAoLZXZhbF9zdGF0dXMYAyABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzEkEKDnBlcl9pbnZvY2F0aW9uGAQgAygLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuSW52b2NhdGlvblJlc3VsdBISCgVlcnJvchgFIAEoCUgBiAEBEhcKCmVycm9yX3R5cGUYBiABKAlIAogBARIoCgdkZXRhaWxzGAcgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIYCgtkdXJhdGlvbl9tcxgIIAEoAUgDiAEBQggKBl9zY29yZUIICgZfZXJyb3JCDQoLX2Vycm9yX3R5cGVCDgoMX2R1cmF0aW9uX21zImMKB1R1cm5SZWYSEAoIdHJhY2VfaWQYASABKAkSDwoHc3Bhbl9pZBgCIAEoCRIaChJldmFsdWF0ZWRfdHJhY2VfaWQYAyABKAkSGQoRZXZhbHVhdGVkX3NwYW5faWQYBCABKAkilwMKC1RyYWNlUmVzdWx0EhAKCHRyYWNlX2lkGAEgASgJEhcKD251bV9pbnZvY2F0aW9ucxgCIAEoBRI9Cg5tZXRyaWNfcmVzdWx0cxgDIAMoCzIlLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLk1ldHJpY1Jlc3VsdBIbChNjb252ZXJzaW9uX3dhcm5pbmdzGAQgAygJEjQKE3BlcmZvcm1hbmNlX21ldHJpY3MYBSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhQKDHNlcnZpY2VfbmFtZRgGIAEoCRISCgphZ2VudF9uYW1lGAcgASgJEhEKCXRyYWNlX2lkcxgIIAMoCRIXCg9jb252ZXJzYXRpb25faWQYCSABKAkSEQoJZ3JvdXBfa2V5GAogASgJEhQKDGV2YWxfY2FzZV9pZBgLIAEoCRIXCg9ldmFsX2Nhc2VfbWF0Y2gYDCABKAkSMwoJdHVybl9yZWZzGA0gAygLMiAuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuVHVyblJlZiKeAQoJUnVuUmVzdWx0EjsKDXRyYWNlX3Jlc3VsdHMYASADKAsyJC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5UcmFjZVJlc3VsdBIOCgZlcnJvcnMYAiADKAkSNAoTcGVyZm9ybWFuY2VfbWV0cmljcxgDIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSDgoGcnVuX2lkGAQgASgJIusCChVFdmFsdWF0ZVRyYWNlc1JlcXVlc3QSMwoGdHJhY2VzGAEgASgLMiMuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuVHJhY2VJbnB1dBIvCgxhZGtfZXZhbF9zZXQYAiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0SAASMwoGcGFyYW1zGAMgASgLMiMuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbFBhcmFtcxJbCg9jcmVkZW50aWFsX3JlZnMYBCADKAsyQi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsdWF0ZVRyYWNlc1JlcXVlc3QuQ3JlZGVudGlhbFJlZnNFbnRyeRpOChNDcmVkZW50aWFsUmVmc0VudHJ5EgsKA2tleRgBIAEoCRImCgV2YWx1ZRgCIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3Q6AjgBQgoKCGV2YWxfc2V0IiUKEkV2YWx1YXRpb25Qcm9ncmVzcxIPCgdtZXNzYWdlGAEgASgJIosBChBHcm91cFBlcmZvcm1hbmNlEhAKCHRyYWNlX2lkGAEgASgJEjQKE3BlcmZvcm1hbmNlX21ldHJpY3MYAiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0Ei8KDnRyYWNlX21ldGFkYXRhGAMgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdCKWAgoWRXZhbHVhdGVUcmFjZXNSZXNwb25zZRI/Cghwcm9ncmVzcxgBIAEoCzIrLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkV2YWx1YXRpb25Qcm9ncmVzc0gAEkAKC3BlcmZvcm1hbmNlGAIgASgLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuR3JvdXBQZXJmb3JtYW5jZUgAEjwKDHRyYWNlX3Jlc3VsdBgDIAEoCzIkLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlRyYWNlUmVzdWx0SAASMgoEZG9uZRgEIAEoCzIiLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlJ1blJlc3VsdEgAQgcKBWV2ZW50KqYBChNUcmFqZWN0b3J5TWF0Y2hUeXBlEiUKIVRSQUpFQ1RPUllfTUFUQ0hfVFlQRV9VTlNQRUNJRklFRBAAEh8KG1RSQUpFQ1RPUllfTUFUQ0hfVFlQRV9FWEFDVBABEiIKHlRSQUpFQ1RPUllfTUFUQ0hfVFlQRV9JTl9PUkRFUhACEiMKH1RSQUpFQ1RPUllfTUFUQ0hfVFlQRV9BTllfT1JERVIQAyqXAQoKRXZhbFN0YXR1cxIbChdFVkFMX1NUQVRVU19VTlNQRUNJRklFRBAAEhYKEkVWQUxfU1RBVFVTX1BBU1NFRBABEhYKEkVWQUxfU1RBVFVTX0ZBSUxFRBACEh0KGUVWQUxfU1RBVFVTX05PVF9FVkFMVUFURUQQAxIdChlFVkFMX1NUQVRVU19JTkZPUk1BVElPTkFMEAQy8gEKEUV2YWx1YXRpb25TZXJ2aWNlEmgKC0xpc3RNZXRyaWNzEisuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuTGlzdE1ldHJpY3NSZXF1ZXN0GiwuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuTGlzdE1ldHJpY3NSZXNwb25zZRJzCg5FdmFsdWF0ZVRyYWNlcxIuLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkV2YWx1YXRlVHJhY2VzUmVxdWVzdBovLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkV2YWx1YXRlVHJhY2VzUmVzcG9uc2UwAUJRWk9naXRodWIuY29tL2FnZW50ZXZhbHMtZGV2L2FnZW50ZXZhbHMvZ2VuL2dvL2FnZW50ZXZhbHMvYXBpL3YxYWxwaGExO2FwaXYxYWxwaGExYgZwcm90bzM", [file_agentevals_api_v1alpha1_common, file_google_protobuf_struct]); + fileDesc("CihhZ2VudGV2YWxzL2FwaS92MWFscGhhMS9ldmFsdWF0aW9uLnByb3RvEhdhZ2VudGV2YWxzLmFwaS52MWFscGhhMSIUChJMaXN0TWV0cmljc1JlcXVlc3QirwEKBk1ldHJpYxIMCgRuYW1lGAEgASgJEhAKCGNhdGVnb3J5GAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEhkKEXJlcXVpcmVzX2V2YWxfc2V0GAQgASgIEhQKDHJlcXVpcmVzX2xsbRgFIAEoCBIUCgxyZXF1aXJlc19nY3AYBiABKAgSGAoQcmVxdWlyZXNfcnVicmljcxgHIAEoCBIPCgd3b3JraW5nGAggASgIIkcKE0xpc3RNZXRyaWNzUmVzcG9uc2USMAoHbWV0cmljcxgBIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLk1ldHJpYyKKAgoQQnVpbHRpbkV2YWx1YXRvchIMCgRuYW1lGAEgASgJEhYKCXRocmVzaG9sZBgCIAEoAUgAiAEBEhMKC2p1ZGdlX21vZGVsGAMgASgJEksKFXRyYWplY3RvcnlfbWF0Y2hfdHlwZRgEIAEoDjIsLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlRyYWplY3RvcnlNYXRjaFR5cGUSFgoOY3JlZGVudGlhbF9yZWYYBSABKAkSFgoOanVkZ2VfYmFzZV91cmwYBiABKAkSMAoHcnVicmljcxgHIAMoCzIfLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLlJ1YnJpY0IMCgpfdGhyZXNob2xkIiIKBlJ1YnJpYxIKCgJpZBgBIAEoCRIMCgR0ZXh0GAIgASgJIq4BCg1Db2RlRXZhbHVhdG9yEgwKBG5hbWUYASABKAkSFgoJdGhyZXNob2xkGAIgASgBSACIAQESFAoHdGltZW91dBgDIAEoBUgBiAEBEicKBmNvbmZpZxgEIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSEAoIZXhlY3V0b3IYBSABKAkSDAoEcGF0aBgGIAEoCUIMCgpfdGhyZXNob2xkQgoKCF90aW1lb3V0Ir8BCg9SZW1vdGVFdmFsdWF0b3ISDAoEbmFtZRgBIAEoCRIWCgl0aHJlc2hvbGQYAiABKAFIAIgBARIUCgd0aW1lb3V0GAMgASgFSAGIAQESJwoGY29uZmlnGAQgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIQCghleGVjdXRvchgFIAEoCRIOCgZzb3VyY2UYBiABKAkSCwoDcmVmGAcgASgJQgwKCl90aHJlc2hvbGRCCgoIX3RpbWVvdXQixQEKCUV2YWx1YXRvchI8CgdidWlsdGluGAEgASgLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuQnVpbHRpbkV2YWx1YXRvckgAEjYKBGNvZGUYAiABKAsyJi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Db2RlRXZhbHVhdG9ySAASOgoGcmVtb3RlGAMgASgLMiguYWdlbnRldmFscy5hcGkudjFhbHBoYTEuUmVtb3RlRXZhbHVhdG9ySABCBgoEa2luZCLyAQoKRXZhbFBhcmFtcxI2CgpldmFsdWF0b3JzGAEgAygLMiIuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbHVhdG9yEiIKFW1heF9jb25jdXJyZW50X3RyYWNlcxgCIAEoBUgAiAEBEiEKFG1heF9jb25jdXJyZW50X2V2YWxzGAMgASgFSAGIAQESMgoIZ3JvdXBfYnkYBCABKA4yIC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5Hcm91cEJ5QhgKFl9tYXhfY29uY3VycmVudF90cmFjZXNCFwoVX21heF9jb25jdXJyZW50X2V2YWxzImUKEEludm9jYXRpb25SZXN1bHQSEgoFc2NvcmUYASABKAFIAIgBARIzCgZzdGF0dXMYAiABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzQggKBl9zY29yZSLYAgoMTWV0cmljUmVzdWx0EhMKC21ldHJpY19uYW1lGAEgASgJEhIKBXNjb3JlGAIgASgBSACIAQESOAoLZXZhbF9zdGF0dXMYAyABKA4yIy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsU3RhdHVzEkEKDnBlcl9pbnZvY2F0aW9uGAQgAygLMikuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuSW52b2NhdGlvblJlc3VsdBISCgVlcnJvchgFIAEoCUgBiAEBEhcKCmVycm9yX3R5cGUYBiABKAlIAogBARIoCgdkZXRhaWxzGAcgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIYCgtkdXJhdGlvbl9tcxgIIAEoAUgDiAEBQggKBl9zY29yZUIICgZfZXJyb3JCDQoLX2Vycm9yX3R5cGVCDgoMX2R1cmF0aW9uX21zImMKB1R1cm5SZWYSEAoIdHJhY2VfaWQYASABKAkSDwoHc3Bhbl9pZBgCIAEoCRIaChJldmFsdWF0ZWRfdHJhY2VfaWQYAyABKAkSGQoRZXZhbHVhdGVkX3NwYW5faWQYBCABKAkilwMKC1RyYWNlUmVzdWx0EhAKCHRyYWNlX2lkGAEgASgJEhcKD251bV9pbnZvY2F0aW9ucxgCIAEoBRI9Cg5tZXRyaWNfcmVzdWx0cxgDIAMoCzIlLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLk1ldHJpY1Jlc3VsdBIbChNjb252ZXJzaW9uX3dhcm5pbmdzGAQgAygJEjQKE3BlcmZvcm1hbmNlX21ldHJpY3MYBSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhQKDHNlcnZpY2VfbmFtZRgGIAEoCRISCgphZ2VudF9uYW1lGAcgASgJEhEKCXRyYWNlX2lkcxgIIAMoCRIXCg9jb252ZXJzYXRpb25faWQYCSABKAkSEQoJZ3JvdXBfa2V5GAogASgJEhQKDGV2YWxfY2FzZV9pZBgLIAEoCRIXCg9ldmFsX2Nhc2VfbWF0Y2gYDCABKAkSMwoJdHVybl9yZWZzGA0gAygLMiAuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuVHVyblJlZiKeAQoJUnVuUmVzdWx0EjsKDXRyYWNlX3Jlc3VsdHMYASADKAsyJC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5UcmFjZVJlc3VsdBIOCgZlcnJvcnMYAiADKAkSNAoTcGVyZm9ybWFuY2VfbWV0cmljcxgDIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSDgoGcnVuX2lkGAQgASgJIvsCChVFdmFsdWF0ZVRyYWNlc1JlcXVlc3QSMwoGdHJhY2VzGAEgASgLMiMuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuVHJhY2VJbnB1dBIvCgxhZGtfZXZhbF9zZXQYAiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0SAASMwoGcGFyYW1zGAMgASgLMiMuYWdlbnRldmFscy5hcGkudjFhbHBoYTEuRXZhbFBhcmFtcxJbCg9jcmVkZW50aWFsX3JlZnMYBCADKAsyQi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsdWF0ZVRyYWNlc1JlcXVlc3QuQ3JlZGVudGlhbFJlZnNFbnRyeRIOCgZydW5faWQYBSABKAkaTgoTQ3JlZGVudGlhbFJlZnNFbnRyeRILCgNrZXkYASABKAkSJgoFdmFsdWUYAiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0OgI4AUIKCghldmFsX3NldCIlChJFdmFsdWF0aW9uUHJvZ3Jlc3MSDwoHbWVzc2FnZRgBIAEoCSKLAQoQR3JvdXBQZXJmb3JtYW5jZRIQCgh0cmFjZV9pZBgBIAEoCRI0ChNwZXJmb3JtYW5jZV9tZXRyaWNzGAIgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIvCg50cmFjZV9tZXRhZGF0YRgDIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QilgIKFkV2YWx1YXRlVHJhY2VzUmVzcG9uc2USPwoIcHJvZ3Jlc3MYASABKAsyKy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsdWF0aW9uUHJvZ3Jlc3NIABJACgtwZXJmb3JtYW5jZRgCIAEoCzIpLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkdyb3VwUGVyZm9ybWFuY2VIABI8Cgx0cmFjZV9yZXN1bHQYAyABKAsyJC5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5UcmFjZVJlc3VsdEgAEjIKBGRvbmUYBCABKAsyIi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5SdW5SZXN1bHRIAEIHCgVldmVudCqmAQoTVHJhamVjdG9yeU1hdGNoVHlwZRIlCiFUUkFKRUNUT1JZX01BVENIX1RZUEVfVU5TUEVDSUZJRUQQABIfChtUUkFKRUNUT1JZX01BVENIX1RZUEVfRVhBQ1QQARIiCh5UUkFKRUNUT1JZX01BVENIX1RZUEVfSU5fT1JERVIQAhIjCh9UUkFKRUNUT1JZX01BVENIX1RZUEVfQU5ZX09SREVSEAMqlwEKCkV2YWxTdGF0dXMSGwoXRVZBTF9TVEFUVVNfVU5TUEVDSUZJRUQQABIWChJFVkFMX1NUQVRVU19QQVNTRUQQARIWChJFVkFMX1NUQVRVU19GQUlMRUQQAhIdChlFVkFMX1NUQVRVU19OT1RfRVZBTFVBVEVEEAMSHQoZRVZBTF9TVEFUVVNfSU5GT1JNQVRJT05BTBAEMvIBChFFdmFsdWF0aW9uU2VydmljZRJoCgtMaXN0TWV0cmljcxIrLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkxpc3RNZXRyaWNzUmVxdWVzdBosLmFnZW50ZXZhbHMuYXBpLnYxYWxwaGExLkxpc3RNZXRyaWNzUmVzcG9uc2UScwoORXZhbHVhdGVUcmFjZXMSLi5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsdWF0ZVRyYWNlc1JlcXVlc3QaLy5hZ2VudGV2YWxzLmFwaS52MWFscGhhMS5FdmFsdWF0ZVRyYWNlc1Jlc3BvbnNlMAFCUVpPZ2l0aHViLmNvbS9hZ2VudGV2YWxzLWRldi9hZ2VudGV2YWxzL2dlbi9nby9hZ2VudGV2YWxzL2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_agentevals_api_v1alpha1_common, file_google_protobuf_struct]); /** * Describes the message agentevals.api.v1alpha1.ListMetricsRequest. From 0323d7ea72c3b8fbeb72b0b601fdaf4babfa46a8 Mon Sep 17 00:00:00 2001 From: krisztianfekete Date: Fri, 13 Nov 2026 16:00:12 +0100 Subject: [PATCH 4/6] fix(otel): name the eval set id agentevals.eval.set.id and document all result labels --- README.md | 2 +- docs/opentelemetry-pipeline.md | 15 +++++++++-- examples/README.md | 4 +-- examples/kubernetes/README.md | 2 +- examples/zero-code-examples/adk/run.py | 2 +- examples/zero-code-examples/langchain/run.py | 2 +- examples/zero-code-examples/ollama/run.py | 2 +- .../zero-code-examples/openai-agents/run.py | 2 +- .../zero-code-examples/pydantic-ai/run.py | 2 +- examples/zero-code-examples/strands/run.py | 2 +- src/agentevals/evaluation_events.py | 19 +++++++++---- src/agentevals/otel/identity.py | 4 +-- src/agentevals/otel/sdk_export.py | 2 +- tests/integration/conftest.py | 2 +- tests/integration/test_live_agents.py | 2 +- tests/otel/test_emit.py | 27 +++++++++++++++++-- tests/test_otlp_receiver.py | 12 ++++----- tests/test_sdk.py | 6 ++--- 18 files changed, 76 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 80b403d..c7b126f 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ python your_agent.py Sessions appear in the UI at `http://localhost:8001` as the agent runs, with tool calls, inputs and outputs, and can be evaluated there. For gRPC exporters use port 4317 with `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`. -Sessions are grouped by `agentevals.session_name`, then `gen_ai.conversation.id`, then `session.id`. `service.instance.id` tells a rerun from the next turn. Set `agentevals.eval_set_id` to associate a session with an eval set. +Sessions are grouped by `agentevals.session_name`, then `gen_ai.conversation.id`, then `session.id`. `service.instance.id` tells a rerun from the next turn. Set `agentevals.eval.set.id` to associate a session with an eval set. Per producer settings, such as turning on message content capture, are in [OpenTelemetry Compatibility](docs/otel-compatibility.md). Working setups for each framework are in [examples/zero-code-examples/](examples/zero-code-examples/). diff --git a/docs/opentelemetry-pipeline.md b/docs/opentelemetry-pipeline.md index daf9e37..05e2043 100644 --- a/docs/opentelemetry-pipeline.md +++ b/docs/opentelemetry-pipeline.md @@ -164,12 +164,23 @@ Each result is a `gen_ai.evaluation.result` log event attached to the span it sc |---|---| | `gen_ai.evaluation.name` | Evaluator name | | `gen_ai.evaluation.score.value` | Score, when there is one | -| `gen_ai.evaluation.score.label` | `pass`, `fail` or `not_evaluated` | +| `gen_ai.evaluation.score.label` | `pass`, `fail`, `not_evaluated` or `informational`, see below | | `error.type` | Exception class when the evaluator failed (no score or label then) | | `gen_ai.evaluation.explanation` | Only with `AGENTEVALS_EVALUATION_EVENTS_EXPLANATION=true` | | `gen_ai.agent.name`, `gen_ai.conversation.id`, `gen_ai.response.id` | Copied from the evaluated span when present | | `agentevals.evaluator.type` | `llm_judge` or `deterministic` for built in metrics | | `agentevals.eval.run.id` | The run: a queued run, an evaluation recorded in run storage, or the `run_id` of an `EvaluateTraces` call | -| `agentevals.eval.case.id`, `agentevals.eval_set.id` | When known | +| `agentevals.eval.case.id`, `agentevals.eval.set.id` | When known | + +| Label | Meaning | +|---|---| +| `pass` | The score met the evaluator's threshold | +| `fail` | The score missed the threshold | +| `not_evaluated` | The evaluator gave no score, for example because the metric needs an eval set | +| `informational` | A value to track that never passes or fails, such as `token_usage_v1` | + +The GenAI conventions give `pass` and `fail` as example labels and ask each implementation to document its own; `not_evaluated` and `informational` are agentevals values. + +The ids have the same names as the resource attributes you set: `agentevals.eval.set.id` (and `agentevals.eval.case.id`) mean the same thing on your telemetry and on these events. Events never contain prompts, responses or tool arguments. The explanation can, which is why it is off by default. diff --git a/examples/README.md b/examples/README.md index 1fad62e..e1c1f34 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ The simplest way to connect any agent to agentevals. Point your standard OTel OT ```bash export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 -export OTEL_RESOURCE_ATTRIBUTES="agentevals.session_name=my-agent,agentevals.eval_set_id=my-eval" +export OTEL_RESOURCE_ATTRIBUTES="agentevals.session_name=my-agent,agentevals.eval.set.id=my-eval" python your_agent.py ``` @@ -38,7 +38,7 @@ This approach works with any framework that has OTel instrumentation: LangChain, | Attribute | Required | Description | |-----------|----------|-------------| | `agentevals.session_name` | no | Groups spans into a named session. Without it, sessions are named `otlp-`. | -| `agentevals.eval_set_id` | no | Associates the session with an eval set for scoring. | +| `agentevals.eval.set.id` | no | Associates the session with an eval set for scoring. | Set them via `OTEL_RESOURCE_ATTRIBUTES` (env var) or `Resource.create()` in code. diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md index 8584c6c..0f970b6 100644 --- a/examples/kubernetes/README.md +++ b/examples/kubernetes/README.md @@ -169,7 +169,7 @@ Body: Empty() Attributes: -> gen_ai.evaluation.name: Str(tool_trajectory_avg_score) -> agentevals.eval.case.id: Str(case_1) - -> agentevals.eval_set.id: Str(manual-1) + -> agentevals.eval.set.id: Str(manual-1) -> agentevals.evaluator.type: Str(deterministic) -> gen_ai.evaluation.score.value: Double(1) -> gen_ai.evaluation.score.label: Str(pass) diff --git a/examples/zero-code-examples/adk/run.py b/examples/zero-code-examples/adk/run.py index df48c56..a7e1d56 100644 --- a/examples/zero-code-examples/adk/run.py +++ b/examples/zero-code-examples/adk/run.py @@ -48,7 +48,7 @@ async def main(): os.environ.setdefault("OTEL_SERVICE_NAME", "adk-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", - "agentevals.eval_set_id=dice_agent_eval,agentevals.session_name=adk-zero-code", + "agentevals.eval.set.id=dice_agent_eval,agentevals.session_name=adk-zero-code", ) # A process level identity, so agentevals can tell a rerun of this script from the next turn. if "service.instance.id=" not in os.environ["OTEL_RESOURCE_ATTRIBUTES"]: diff --git a/examples/zero-code-examples/langchain/run.py b/examples/zero-code-examples/langchain/run.py index 363833c..f65ba66 100644 --- a/examples/zero-code-examples/langchain/run.py +++ b/examples/zero-code-examples/langchain/run.py @@ -52,7 +52,7 @@ def main(): os.environ.setdefault("OTEL_SERVICE_NAME", "langchain-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", - "agentevals.eval_set_id=langchain_agent_eval,agentevals.session_name=langchain-zero-code", + "agentevals.eval.set.id=langchain_agent_eval,agentevals.session_name=langchain-zero-code", ) # A process level identity, so agentevals can tell a rerun of this script from the next turn. if "service.instance.id=" not in os.environ["OTEL_RESOURCE_ATTRIBUTES"]: diff --git a/examples/zero-code-examples/ollama/run.py b/examples/zero-code-examples/ollama/run.py index 73a764f..b194e6e 100644 --- a/examples/zero-code-examples/ollama/run.py +++ b/examples/zero-code-examples/ollama/run.py @@ -116,7 +116,7 @@ def main(): os.environ.setdefault("OTEL_SERVICE_NAME", "ollama-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", - "agentevals.eval_set_id=langchain_local_ollama_openai_eval,agentevals.session_name=langchain-ollama-openai-zero-code", + "agentevals.eval.set.id=langchain_local_ollama_openai_eval,agentevals.session_name=langchain-ollama-openai-zero-code", ) # A process level identity, so agentevals can tell a rerun of this script from the next turn. if "service.instance.id=" not in os.environ["OTEL_RESOURCE_ATTRIBUTES"]: diff --git a/examples/zero-code-examples/openai-agents/run.py b/examples/zero-code-examples/openai-agents/run.py index 9f18d07..32abf0e 100644 --- a/examples/zero-code-examples/openai-agents/run.py +++ b/examples/zero-code-examples/openai-agents/run.py @@ -62,7 +62,7 @@ def main(): os.environ.setdefault("OTEL_SERVICE_NAME", "openai-agents-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", - "agentevals.eval_set_id=openai_agents_eval,agentevals.session_name=openai-agents-zero-code", + "agentevals.eval.set.id=openai_agents_eval,agentevals.session_name=openai-agents-zero-code", ) # A process level identity, so agentevals can tell a rerun of this script from the next turn. if "service.instance.id=" not in os.environ["OTEL_RESOURCE_ATTRIBUTES"]: diff --git a/examples/zero-code-examples/pydantic-ai/run.py b/examples/zero-code-examples/pydantic-ai/run.py index 6b79c82..1183b3b 100644 --- a/examples/zero-code-examples/pydantic-ai/run.py +++ b/examples/zero-code-examples/pydantic-ai/run.py @@ -58,7 +58,7 @@ def main(): os.environ.setdefault("OTEL_SERVICE_NAME", "pydantic-ai-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", - "agentevals.eval_set_id=pydantic_ai_eval,agentevals.session_name=pydantic-ai-zero-code", + "agentevals.eval.set.id=pydantic_ai_eval,agentevals.session_name=pydantic-ai-zero-code", ) # A process level identity, so agentevals can tell a rerun of this script from the next turn. if "service.instance.id=" not in os.environ["OTEL_RESOURCE_ATTRIBUTES"]: diff --git a/examples/zero-code-examples/strands/run.py b/examples/zero-code-examples/strands/run.py index 5c5a0fe..ba02972 100644 --- a/examples/zero-code-examples/strands/run.py +++ b/examples/zero-code-examples/strands/run.py @@ -44,7 +44,7 @@ def main(): os.environ.setdefault("OTEL_SERVICE_NAME", "strands-agent") os.environ.setdefault( "OTEL_RESOURCE_ATTRIBUTES", - "agentevals.eval_set_id=strands_agent_eval,agentevals.session_name=strands-zero-code", + "agentevals.eval.set.id=strands_agent_eval,agentevals.session_name=strands-zero-code", ) # A process level identity, so agentevals can tell a rerun of this script from the next turn. if "service.instance.id=" not in os.environ["OTEL_RESOURCE_ATTRIBUTES"]: diff --git a/src/agentevals/evaluation_events.py b/src/agentevals/evaluation_events.py index 9c1920b..eccbd09 100644 --- a/src/agentevals/evaluation_events.py +++ b/src/agentevals/evaluation_events.py @@ -3,8 +3,16 @@ Each record is parented to the GenAI span it judges, so a backend shows the score next to the operation, and carries no content: no user input, model output or tool arguments. Metrics with per turn scores give one record per turn; conversation level metrics give one record on the last -turn. Labels are ``pass``, ``fail`` and ``not_evaluated``; an evaluator error gives ``error.type`` -and no score or label. +turn. An evaluator error gives ``error.type`` and no score or label. Otherwise the label is one of: + +* ``pass`` and ``fail``: the score met the threshold, or did not; +* ``not_evaluated``: the evaluator gave no score (no eval set, say); +* ``informational``: a value to track that never passes or fails (the efficiency metrics). + +The GenAI conventions give ``pass`` and ``fail`` as examples and ask an implementation to document +its values; ``docs/opentelemetry-pipeline.md`` lists these for users. The ids use one name on the +records and on the resource: ``agentevals.eval.run.id``, ``agentevals.eval.case.id`` and +``agentevals.eval.set.id``. Off unless turned on, because it sends results to an external backend. Precedence: @@ -28,7 +36,8 @@ from collections.abc import Iterable, Mapping, Sequence from typing import TYPE_CHECKING, Any -from .otel.identity import EMITTER_SCOPE +from .genai.matching import EVAL_CASE_ID +from .otel.identity import EMITTER_SCOPE, EVAL_SET_ID from .otel.model import Span, attr_str from .telemetry import PROCESS_INSTANCE_ID, build_resource, otlp_protocol from .telemetry import env_flag as _env_flag @@ -198,8 +207,8 @@ def _emit(self, conversation, metrics, evaluators, spans, eval_case_id, eval_set common: dict[str, Any] = {} for key, value in ( ("agentevals.eval.run.id", run_id), - ("agentevals.eval.case.id", eval_case_id), - ("agentevals.eval_set.id", eval_set_id), + (EVAL_CASE_ID, eval_case_id), + (EVAL_SET_ID, eval_set_id), ): if value: common[key] = value diff --git a/src/agentevals/otel/identity.py b/src/agentevals/otel/identity.py index f1d1999..b9da59e 100644 --- a/src/agentevals/otel/identity.py +++ b/src/agentevals/otel/identity.py @@ -2,7 +2,7 @@ ``agentevals.session_name`` names a session (reruns of a finished name become ``name-2``). ``agentevals.session.run_id`` separates runs that reuse a name; only the SDK sets it. -``agentevals.eval_set_id`` and ``agentevals.metadata.*`` are shown with the session. +``agentevals.eval.set.id`` and ``agentevals.metadata.*`` are shown with the session. """ from __future__ import annotations @@ -11,7 +11,7 @@ SESSION_NAME = "agentevals.session_name" SESSION_RUN_ID = "agentevals.session.run_id" -EVAL_SET_ID = "agentevals.eval_set_id" +EVAL_SET_ID = "agentevals.eval.set.id" METADATA_PREFIX = "agentevals.metadata." # Instrumentation scope of the evaluation result records agentevals emits. diff --git a/src/agentevals/otel/sdk_export.py b/src/agentevals/otel/sdk_export.py index 641791d..7bbd0dc 100644 --- a/src/agentevals/otel/sdk_export.py +++ b/src/agentevals/otel/sdk_export.py @@ -4,7 +4,7 @@ session cannot live on the provider resource. Instead one :class:`SessionSpanProcessor` per provider decides which session a span belongs to when it starts, and when it ends forwards a copy whose resource carries the session identity (``agentevals.session_name``, -``agentevals.eval_set_id``, ``agentevals.session.run_id``, ``agentevals.metadata.*``) to its own +``agentevals.eval.set.id``, ``agentevals.session.run_id``, ``agentevals.metadata.*``) to its own OTLP exporter. Spans outside every session are not exported, and the application's own exporters never see these attributes. Baggage is not used, because it would propagate into outbound requests. diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e17fe40..55e313b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -191,7 +191,7 @@ def make_trace_request( if session_name: resource_attrs.append({"key": "agentevals.session_name", "value": {"stringValue": session_name}}) if eval_set_id: - resource_attrs.append({"key": "agentevals.eval_set_id", "value": {"stringValue": eval_set_id}}) + resource_attrs.append({"key": "agentevals.eval.set.id", "value": {"stringValue": eval_set_id}}) return { "resourceSpans": [ { diff --git a/tests/integration/test_live_agents.py b/tests/integration/test_live_agents.py index db97aae..972ba96 100644 --- a/tests/integration/test_live_agents.py +++ b/tests/integration/test_live_agents.py @@ -57,7 +57,7 @@ def _run_agent( env = { **os.environ, "OTEL_EXPORTER_OTLP_ENDPOINT": f"http://127.0.0.1:{otlp_http_port}", - "OTEL_RESOURCE_ATTRIBUTES": (f"agentevals.eval_set_id={eval_set_id},agentevals.session_name={session_name}"), + "OTEL_RESOURCE_ATTRIBUTES": (f"agentevals.eval.set.id={eval_set_id},agentevals.session_name={session_name}"), **(extra_env or {}), } return subprocess.run( diff --git a/tests/otel/test_emit.py b/tests/otel/test_emit.py index af316a5..bd6e8fa 100644 --- a/tests/otel/test_emit.py +++ b/tests/otel/test_emit.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +import re +from pathlib import Path import pytest from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter @@ -12,7 +14,9 @@ from agentevals.api.rpc import convert from agentevals.config import BuiltinMetricDef, CodeEvaluatorDef, EvalParams from agentevals.genai.extract import extract_conversation +from agentevals.genai.matching import EVAL_CASE_ID from agentevals.otel.decode import decode_json_document +from agentevals.otel.identity import EVAL_SET_ID from agentevals.otel.model import build_traces from agentevals.runner import MetricResult, run_evaluation_from_traces @@ -30,7 +34,7 @@ "agentevals.evaluator.type", "agentevals.eval.run.id", "agentevals.eval.case.id", - "agentevals.eval_set.id", + "agentevals.eval.set.id", } @@ -114,7 +118,7 @@ def test_per_turn_scores_give_one_record_per_turn_on_its_anchor(self): assert ( attrs[0]["agentevals.eval.run.id"], attrs[0]["agentevals.eval.case.id"], - attrs[0]["agentevals.eval_set.id"], + attrs[0]["agentevals.eval.set.id"], ) == ("run-1", "case", "set") assert (attrs[0]["gen_ai.agent.name"], attrs[0]["gen_ai.conversation.id"], attrs[0]["gen_ai.response.id"]) == ( "helper", @@ -167,6 +171,25 @@ def test_informational_values_are_labelled_informational(self): (None, "not_evaluated"), ] + def test_every_label_is_documented(self): + doc = (Path(__file__).resolve().parents[2] / "docs" / "opentelemetry-pipeline.md").read_text() + table = doc.split("| Label | Meaning |")[1].split("\n\n")[0] + documented = set(re.findall(r"^\| `([a-z_]+)` \|", table, re.MULTILINE)) + assert documented == {*emit._LABELS.values(), emit.LABEL_NOT_EVALUATED} + + def test_ids_are_named_as_the_attributes_agentevals_reads(self): + conversation, index = _two_turns() + metric = MetricResult(metric_name="judge", score=1.0) + [record, *_] = _emit( + conversation, + index, + [metric], + [CodeEvaluatorDef(name="judge", path="j.py")], + eval_case_id="case", + eval_set_id="set", + ) + assert (record.attributes[EVAL_CASE_ID], record.attributes[EVAL_SET_ID]) == ("case", "set") + def test_records_never_carry_content(self): conversation, index = _two_turns() metric = MetricResult( diff --git a/tests/test_otlp_receiver.py b/tests/test_otlp_receiver.py index e73103a..441ebbd 100644 --- a/tests/test_otlp_receiver.py +++ b/tests/test_otlp_receiver.py @@ -104,7 +104,7 @@ def _request(spans, resource_attrs=None, scope_name="", scope_version="") -> dic def _named(name: str, eval_set_id: str | None = None) -> list[dict]: attrs = [_attr("agentevals.session_name", name)] if eval_set_id: - attrs.append(_attr("agentevals.eval_set_id", eval_set_id)) + attrs.append(_attr("agentevals.eval.set.id", eval_set_id)) return attrs @@ -371,11 +371,11 @@ def test_attribute_types_are_preserved(self): def test_resource_and_scope_are_preserved(self): resource = [ KeyValue(key="service.name", value=AnyValue(string_value="test-agent")), - KeyValue(key="agentevals.eval_set_id", value=AnyValue(string_value="eval-1")), + KeyValue(key="agentevals.eval.set.id", value=AnyValue(string_value="eval-1")), ] raw = _pb_request([_pb_span()], resource, "gcp.vertex.agent", "1.2.3").SerializeToString() span = decode_traces_protobuf(raw).spans[0] - assert span.resource.attributes == {"service.name": "test-agent", "agentevals.eval_set_id": "eval-1"} + assert span.resource.attributes == {"service.name": "test-agent", "agentevals.eval.set.id": "eval-1"} assert (span.scope.name, span.scope.version) == ("gcp.vertex.agent", "1.2.3") def test_multiple_spans(self): @@ -410,7 +410,7 @@ def test_protobuf_and_json_decode_to_the_same_span(self): class TestSessionMetadata: def test_excludes_agentevals_attributes(self): - meta = session_metadata({"agentevals.eval_set_id": "e1", "service.name": "my-agent", "deployment.env": "dev"}) + meta = session_metadata({"agentevals.eval.set.id": "e1", "service.name": "my-agent", "deployment.env": "dev"}) assert meta == {"service.name": "my-agent", "deployment.env": "dev"} def test_unprefixes_sdk_metadata(self): @@ -429,7 +429,7 @@ def test_unusable_session_name_is_ignored(self, value): def test_non_string_eval_set_id_is_ignored(self): store, _ = _store() - attrs = [*_named("s1"), {"key": "agentevals.eval_set_id", "value": {"kvlistValue": {"values": []}}}] + attrs = [*_named("s1"), {"key": "agentevals.eval.set.id", "value": {"kvlistValue": {"values": []}}}] _ingest(store, _request([_span()], attrs)) assert store.sessions["s1"].eval_set_id is None @@ -958,7 +958,7 @@ async def test_second_sigint_force_stops_grpc(self): def _pb_named(name: str, eval_set_id: str | None = None) -> list[KeyValue]: attrs = [KeyValue(key="agentevals.session_name", value=AnyValue(string_value=name))] if eval_set_id: - attrs.append(KeyValue(key="agentevals.eval_set_id", value=AnyValue(string_value=eval_set_id))) + attrs.append(KeyValue(key="agentevals.eval.set.id", value=AnyValue(string_value=eval_set_id))) return attrs diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 2047b8a..0dfd054 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -304,7 +304,7 @@ def test_session_spans_carry_the_session_resource(self, exported): assert [s.name for s in spans] == ["work"] resource = _resource(spans[0]) assert resource["agentevals.session_name"] == "s1" - assert resource["agentevals.eval_set_id"] == "e1" + assert resource["agentevals.eval.set.id"] == "e1" assert resource["agentevals.metadata.a"] == 1 assert resource["agentevals.metadata.b"] == "two" assert len(resource["agentevals.session.run_id"]) == 32 @@ -314,7 +314,7 @@ def test_eval_set_id_falls_back_to_instance(self, exported): app = AgentEvals(auto_instrument=False, eval_set_id="from-init") with app.session(session_name="s1", tracer_provider=provider): provider.get_tracer("t").start_span("work").end() - assert _resource(exported.get_finished_spans()[0])["agentevals.eval_set_id"] == "from-init" + assert _resource(exported.get_finished_spans()[0])["agentevals.eval.set.id"] == "from-init" def test_each_session_gets_its_own_run_id(self, exported): provider = TracerProvider() @@ -396,7 +396,7 @@ async def test_session_spans_carry_the_session_resource(self, exported): assert name == "async-s1" provider.get_tracer("t").start_span("work").end() resource = _resource(exported.get_finished_spans()[0]) - assert (resource["agentevals.session_name"], resource["agentevals.eval_set_id"]) == ("async-s1", "e1") + assert (resource["agentevals.session_name"], resource["agentevals.eval.set.id"]) == ("async-s1", "e1") async def test_concurrent_sessions_stay_apart(self, exported): provider = TracerProvider() From 3447face7a6a054a6816177699fb417b0b933bf5 Mon Sep 17 00:00:00 2001 From: krisztianfekete Date: Fri, 13 Nov 2026 16:41:34 +0100 Subject: [PATCH 5/6] fix(genai): require a named agent to resume, join only matching tool calls across segments --- docs/otel-compatibility.md | 2 +- src/agentevals/genai/extract.py | 38 ++++++++++++++++++------------- tests/genai/test_resumed_turns.py | 38 +++++++++++++++++++++++++++---- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/docs/otel-compatibility.md b/docs/otel-compatibility.md index 894f9f9..4d8f43a 100644 --- a/docs/otel-compatibility.md +++ b/docs/otel-compatibility.md @@ -32,7 +32,7 @@ Working setups for each are in [examples/zero-code-examples](../examples/zero-co If a framework only instruments the model client (each model call is its own trace), agentevals joins a call that answers a tool result to the turn before it. This needs message content. -If a runtime pauses a turn (for an approval or a question) and runs the rest as a new `invoke_agent` span, often in a new trace, add a span link from the new span to the `invoke_agent` span that started the turn. agentevals then scores both parts as one turn: the user input of the first, the final answer of the last, and the tools of both. The new span must have the same `gen_ai.agent.name` and no input messages of its own. Linked traces are evaluated together whatever the grouping. +If a runtime pauses a turn (for an approval or a question) and runs the rest as a new `invoke_agent` span, often in a new trace, add a span link from the new span to the `invoke_agent` span that started the turn. agentevals then scores both parts as one turn: the user input of the first, the final answer of the last, and the tools of both. Both spans must carry the same `gen_ai.agent.name`, and the new span no input messages of its own. Linked traces are evaluated together whatever the grouping. ## Sessions diff --git a/src/agentevals/genai/extract.py b/src/agentevals/genai/extract.py index 3674519..52692d1 100644 --- a/src/agentevals/genai/extract.py +++ b/src/agentevals/genai/extract.py @@ -26,7 +26,7 @@ response must answer one of the requested calls. This reads message content; with content capture off the turns stay split and the later one carries a warning. * An agent anchored turn whose anchor records no input of its own and links to the anchor of an - earlier turn of the same agent resumes that turn (a runtime that pauses a turn for an approval or + earlier turn of the same named agent resumes that turn (a runtime that pauses a turn for an approval or a question runs the rest of it as a new agent span, often in a new trace). The link is structural, so this needs no message content. A tool call recorded in both parts counts once, preferring the span that finished without error. @@ -727,34 +727,40 @@ def resume_links(view: SpanView) -> list[SpanRef]: def resumes(view: SpanView, origin: SpanView) -> bool: - """Whether ``view``, linked to ``origin``, continues the turn ``origin`` started.""" + """Whether ``view``, linked to ``origin``, continues the turn ``origin`` started. Both must name + the same agent: a link between unnamed agent spans establishes no identity.""" + name = attr_str(view.attrs, sc.AGENT_NAME) return ( - classify(origin) == AGENT + bool(name) + and classify(origin) == AGENT and origin.span.start_time_unix_nano < view.span.start_time_unix_nano - and attr_str(origin.attrs, sc.AGENT_NAME) == attr_str(view.attrs, sc.AGENT_NAME) + and attr_str(origin.attrs, sc.AGENT_NAME) == name ) -def _one_per_call_id(tools: list[ToolCall]) -> list[ToolCall]: - out: list[ToolCall] = [] - seen: dict[str, int] = {} - for tool in tools: - index = seen.get(tool.call_id) if tool.call_id else None +def _join_span_tools(earlier: Sequence[ToolCall], later: Sequence[ToolCall]) -> list[ToolCall]: + """The tool spans of two parts of one turn. A call both parts recorded (same call id, tool and + agent: open at the pause, finished after it) counts once. The span that finished without error + wins, and keeps arguments or a result only the other one recorded.""" + out = [t.model_copy() for t in earlier if t.ref is not None] + unmatched = {(t.call_id, t.name, t.agent_name): i for i, t in enumerate(out) if t.call_id} + for tool in (t.model_copy() for t in later if t.ref is not None): + index = unmatched.pop((tool.call_id, tool.name, tool.agent_name), None) if tool.call_id else None if index is None: - if tool.call_id: - seen[tool.call_id] = len(out) out.append(tool) - elif out[index].is_error and not tool.is_error: - out[index] = tool + continue + kept, other = (tool, out[index]) if out[index].is_error and not tool.is_error else (out[index], tool) + for key in ("arguments", "result"): + if _is_empty(getattr(kept, key)) and not _is_empty(getattr(other, key)): + setattr(kept, key, getattr(other, key)) + out[index] = kept return out def _merge(previous: Turn, turn: Turn) -> Turn: calls = sorted([*previous.llm_calls, *turn.llm_calls], key=lambda c: (c.start_ns, c.end_ns)) warnings = [*previous.warnings, *turn.warnings] - span_tools = _one_per_call_id( - [t.model_copy() for t in (*previous.tool_calls, *turn.tool_calls) if t.ref is not None] - ) + span_tools = _join_span_tools(previous.tool_calls, turn.tool_calls) if span_tools: tools = _fill_from_parts(span_tools, calls, warnings) else: diff --git a/tests/genai/test_resumed_turns.py b/tests/genai/test_resumed_turns.py index c526351..17d8348 100644 --- a/tests/genai/test_resumed_turns.py +++ b/tests/genai/test_resumed_turns.py @@ -23,19 +23,23 @@ def _traces(*spans: dict): def _agent(trace, span_id, start, end, inputs=None, outputs=None, links=(), name="assistant"): - attrs = {"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": name, "gen_ai.conversation.id": "ctx-1"} + attrs = {"gen_ai.operation.name": "invoke_agent", "gen_ai.conversation.id": "ctx-1"} + if name: + attrs["gen_ai.agent.name"] = name if inputs: attrs["gen_ai.input.messages"] = inputs if outputs: attrs["gen_ai.output.messages"] = outputs - return span(trace, span_id, None, f"invoke_agent {name}", attrs, start, end, links=links) + return span(trace, span_id, None, "invoke_agent", attrs, start, end, links=links) -def _tool(trace, span_id, parent, start, end, error=None): - attrs = {"gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": "delete_pod", "gen_ai.tool.call.id": "call_1"} +def _tool(trace, span_id, parent, start, end, error=None, name="delete_pod", arguments=None): + attrs = {"gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": name, "gen_ai.tool.call.id": "call_1"} if error: attrs["error.type"] = error - return span(trace, span_id, parent, "execute_tool delete_pod", attrs, start, end) + if arguments: + attrs["gen_ai.tool.call.arguments"] = arguments + return span(trace, span_id, parent, f"execute_tool {name}", attrs, start, end) def _initial(trace="a", span_id="a1", start=1_000, question="Delete pod web-1", name="assistant"): @@ -87,6 +91,24 @@ def test_a_tool_left_unfinished_without_a_later_finish_stays_as_recorded(): assert [(t.ref.span_id, t.error_type) for t in turn.tool_calls] == [(sid("a1t"), "unfinished")] +def test_the_finished_call_keeps_arguments_only_the_paused_span_recorded(): + initial = [ + _agent("a", "a1", 1_000, 3_000, inputs=[user("Delete pod web-1")]), + chat(trace="a", span_id="a1c", parent="a1", start=1_100, end=2_000, content=False, finish="tool_call"), + _tool("a", "a1t", "a1", 1_200, 3_000, error="unfinished", arguments={"name": "web-1"}), + ] + [turn] = extract_conversation(_traces(*initial, *_resumed()), "ctx-1").turns + assert [(t.ref.span_id, t.arguments, t.is_error) for t in turn.tool_calls] == [ + (sid("a2t"), {"name": "web-1"}, False) + ] + + +def test_calls_sharing_an_id_inside_one_part_stay_apart(): + resumed = [*_resumed(), _tool("b", "a2d", "a2", 60_200, 60_900, name="describe_pod")] + [turn] = extract_conversation(_traces(*_initial(), *resumed), "ctx-1").turns + assert sorted(t.name for t in turn.tool_calls) == ["delete_pod", "describe_pod"] + + def test_every_resumed_segment_joins_the_same_turn(): second = _resumed(trace="c", span_id="a3", start=90_000, answer="Done, and cleaned up") [turn] = extract_conversation(_traces(*_initial(), *_resumed(), *second), "ctx-1").turns @@ -114,6 +136,12 @@ def test_a_link_to_another_agent_is_not_a_resume(): assert len(extract_conversation(_traces(*_initial(), *resumed), "ctx-1").turns) == 2 +def test_a_link_between_unnamed_agents_is_not_a_resume(): + traces = _traces(*_initial(name=None), *_resumed(name=None)) + assert len(extract_conversation(traces, "ctx-1").turns) == 2 + assert len(group_traces(traces, by_conversation=False)) == 2 + + def test_a_resumed_segment_without_its_origin_keeps_a_warning(): [turn] = extract_conversation(_traces(*_resumed()), "ctx-1").turns assert text_of(turn.final_output) == "Deleted web-1" From b5b46a9e7a0ba76aeac64b304ebb77b83e2b4857 Mon Sep 17 00:00:00 2001 From: krisztianfekete Date: Fri, 13 Nov 2026 16:41:34 +0100 Subject: [PATCH 6/6] fix(api): never record into a taken run id, pass the run id through evaluate-sessions --- src/agentevals/api/evaluation.py | 9 ++++---- src/agentevals/api/streaming_routes.py | 10 ++++++++- src/agentevals/run/service.py | 17 +++++++++++++- tests/run/test_service.py | 20 ++++++++++++++++- tests/storage/test_postgres_repos.py | 31 ++++++++++++++++++++++++++ tests/test_api.py | 23 +++++++++++++++++++ 6 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/agentevals/api/evaluation.py b/src/agentevals/api/evaluation.py index a41c2ab..28143db 100644 --- a/src/agentevals/api/evaluation.py +++ b/src/agentevals/api/evaluation.py @@ -12,7 +12,7 @@ import logging from collections.abc import AsyncIterator from dataclasses import dataclass, field, replace -from typing import TYPE_CHECKING, Any +from typing import Any from uuid import UUID, uuid4 from google.adk.evaluation.eval_set import EvalSet @@ -21,12 +21,10 @@ from ..genai.extract import extract_conversation from ..otel.model import Trace from ..resolvers import reset_resolved_credentials, resolve_credential_refs, set_resolved_credentials +from ..run.service import RunIdTaken, RunService from ..runner import RunResult, TraceResult, evaluation_groups, run_evaluation_from_traces from ..trace_metrics import extract_performance_metrics, extract_trace_metadata -if TYPE_CHECKING: - from ..run.service import RunService - logger = logging.getLogger(__name__) @@ -198,6 +196,9 @@ async def persist_evaluation( run_result=result, ) return str(run.run_id) + except RunIdTaken as exc: + logger.warning("Not recording the evaluation: %s", exc) + return None except Exception: logger.exception("Failed to record the evaluation as a run; the result is still returned") return None diff --git a/src/agentevals/api/streaming_routes.py b/src/agentevals/api/streaming_routes.py index 2df950c..ee1d96b 100644 --- a/src/agentevals/api/streaming_routes.py +++ b/src/agentevals/api/streaming_routes.py @@ -7,6 +7,7 @@ import logging import os from typing import TYPE_CHECKING +from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse @@ -116,6 +117,7 @@ async def _persist_sessions_run( session_ids: list[str], run_results: list[RunResult], session_errors: list[str], + run_id: str | None, ) -> None: """Best-effort: persist a UI-driven session evaluation as a single Run row (plus Result rows) when ``app.state.run_service`` is configured (postgres @@ -140,6 +142,7 @@ async def _persist_sessions_run( trace_format="otlp-json", upload_filenames=session_ids, run_result=combined, + run_id=UUID(run_id) if run_id else None, ) except Exception: logger.exception("failed to persist evaluate-sessions run; results still returned to caller") @@ -173,13 +176,17 @@ async def evaluate_sessions( logger.info("Evaluating %d complete sessions (of %d total)", len(sessions_to_evaluate), len(manager.sessions)) + # Chosen up front so the result records emitted while evaluating carry the recorded run's id. + run_id = str(uuid4()) if getattr(http_request.app.state, "run_service", None) is not None else None sem = asyncio.Semaphore(5) async def eval_one_session(session_id: str, session) -> tuple[SessionEvalResult, RunResult | None]: async with sem: try: traces = manager.session_traces(session) - eval_result = await run_evaluation_from_traces(traces, params, eval_set, group_key=session_id) + eval_result = await run_evaluation_from_traces( + traces, params, eval_set, group_key=session_id, run_id=run_id + ) if eval_result.trace_results: trace_result = eval_result.trace_results[0] @@ -221,6 +228,7 @@ async def eval_one_session(session_id: str, session) -> tuple[SessionEvalResult, session_ids=[sid for sid, _ in sessions_to_evaluate], run_results=[run_result for _, run_result in evaluated if run_result is not None], session_errors=[f"{r.session_id}: {r.error}" for r in results if r.error], + run_id=run_id, ) return StandardResponse( diff --git a/src/agentevals/run/service.py b/src/agentevals/run/service.py index f3eb0a2..58bded1 100644 --- a/src/agentevals/run/service.py +++ b/src/agentevals/run/service.py @@ -38,6 +38,14 @@ def __init__(self, persisted: Run) -> None: self.persisted = persisted +class RunIdTaken(Exception): + """Raised when an evaluation is recorded under a run id another run already holds.""" + + def __init__(self, persisted: Run) -> None: + super().__init__(f"run {persisted.run_id} already exists") + self.persisted = persisted + + class RunService: def __init__(self, runs: RunRepository, results: ResultRepository) -> None: self._runs = runs @@ -100,6 +108,9 @@ async def record_eval_run( a terminal status. Two writes (create + update_status), but the public :class:`RunRepository` API stays clean of executor-only schema knowledge. + + Raises :class:`RunIdTaken`, writing nothing, when another run holds + ``run_id``. """ filenames = list(upload_filenames or []) target = TraceTarget( @@ -124,7 +135,11 @@ async def record_eval_run( worker_id="sync:/api/evaluate", started_at=datetime.now(timezone.utc), ) - await self._runs.create(run) + # create() inserts only when the id is free and otherwise returns the run holding it, which + # never shares this start time. + persisted = await self._runs.create(run) + if persisted.started_at != run.started_at: + raise RunIdTaken(persisted) results = build_results(run_id, params, run_result) await self._results.upsert_many(run_id, results) diff --git a/tests/run/test_service.py b/tests/run/test_service.py index 8ff5b98..0e628a4 100644 --- a/tests/run/test_service.py +++ b/tests/run/test_service.py @@ -7,7 +7,7 @@ import pytest from agentevals.config import BuiltinMetricDef, EvalParams -from agentevals.run.service import RunService, RunSubmitConflict +from agentevals.run.service import RunIdTaken, RunService, RunSubmitConflict from agentevals.runner import MetricResult, RunResult, TraceResult from agentevals.storage.models import RunSpec, RunStatus, TraceTarget from agentevals.storage.repos.memory import MemoryRepos @@ -154,6 +154,24 @@ async def test_each_call_creates_distinct_run(self, service): assert a.run_id != b.run_id assert len(await repos.runs.list()) == 2 + async def test_a_given_run_id_is_used(self, service): + svc, _ = service + run_id = uuid4() + assert (await self._record(svc, run_id=run_id)).run_id == run_id + + @pytest.mark.parametrize("taken_by", ["submitted run", "recorded evaluation"]) + async def test_a_run_id_already_taken_is_refused_and_the_run_left_alone(self, service, taken_by): + svc, repos = service + if taken_by == "submitted run": + existing = await svc.submit(run_id=None, spec=_spec()) + else: + existing = await self._record(svc) + before = await repos.results.list_by_run(existing.run_id) + with pytest.raises(RunIdTaken): + await self._record(svc, run_id=existing.run_id, run_result=self._run_result(errors=["other"])) + assert await repos.runs.get(existing.run_id) == existing + assert await repos.results.list_by_run(existing.run_id) == before + async def test_persisted_run_has_attempt_and_started_at(self, service): """Regression: PostgresRunRepository.create previously hardcoded attempt=0 and silently dropped started_at, so the persisted row was diff --git a/tests/storage/test_postgres_repos.py b/tests/storage/test_postgres_repos.py index 6cd60f2..a3e4edb 100644 --- a/tests/storage/test_postgres_repos.py +++ b/tests/storage/test_postgres_repos.py @@ -12,6 +12,9 @@ import pytest +from agentevals.config import BuiltinMetricDef, EvalParams +from agentevals.run.service import RunIdTaken, RunService +from agentevals.runner import MetricResult, RunResult, TraceResult from agentevals.storage.models import Result, ResultStatus, Run, RunSpec, RunStatus, TraceTarget from agentevals.storage.postgres.migrator import Migrator from agentevals.storage.repos.postgres import PostgresRepos @@ -134,3 +137,31 @@ async def test_finished_runs_are_never_touched(self, db): before = await repos.runs.get(run_id) assert await _claim(repos) is None assert await repos.runs.get(run_id) == before + + +class TestRecordedEvaluations: + async def _record(self, repos: PostgresRepos, run_id: UUID | None = None) -> Run: + result = RunResult(trace_results=[TraceResult(trace_id="t1", metric_results=[MetricResult(metric_name="m1")])]) + return await RunService(repos.runs, repos.results).record_eval_run( + params=EvalParams(evaluators=[BuiltinMetricDef(name="m1")]), + eval_set_dict=None, + trace_format=None, + upload_filenames=["t1.json"], + run_result=result, + run_id=run_id, + ) + + async def test_a_free_run_id_is_recorded(self, db): + repos, _ = db + run_id = uuid4() + assert (await self._record(repos, run_id)).run_id == run_id + assert len(await repos.results.list_by_run(run_id)) == 1 + + async def test_a_taken_run_id_is_refused_and_the_run_left_alone(self, db): + repos, _ = db + run_id = await _queued(repos) + before = await repos.runs.get(run_id) + with pytest.raises(RunIdTaken): + await self._record(repos, run_id) + assert await repos.runs.get(run_id) == before + assert await repos.results.list_by_run(run_id) == [] diff --git a/tests/test_api.py b/tests/test_api.py index f2c3fed..997e2c7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import io import json import os @@ -33,7 +34,9 @@ from agentevals.api.routes import _camel_keys, router from agentevals.api.streaming_routes import streaming_router from agentevals.otel.decode import decode_bare_spans_json +from agentevals.run.service import RunService from agentevals.runner import MetricResult, RunResult, TraceResult +from agentevals.storage.repos.memory import MemoryRepos from agentevals.streaming.exports import EXPORT_DIR # --------------------------------------------------------------------------- @@ -1351,6 +1354,26 @@ def test_evaluate_sessions_eval_failure(self, mock_create_eval, mock_eval): results = body["data"]["results"] assert any(r.get("error") for r in results) + @patch("agentevals.api.streaming_routes.run_evaluation_from_traces", new_callable=AsyncMock) + @patch("agentevals.api.streaming_routes._do_create_eval_set", new_callable=AsyncMock) + def test_records_carry_the_recorded_run_id(self, mock_create_eval, mock_eval): + _reset(self.mgr) + _add_session(self.mgr, "golden") + _add_session(self.mgr, "other") + mock_create_eval.return_value = StandardResponse( + data=CreateEvalSetData(eval_set={"eval_set_id": "e1", "eval_cases": []}, num_invocations=1) + ) + mock_eval.return_value = _make_run_result() + repos = MemoryRepos.create() + self.app.state.run_service = RunService(repos.runs, repos.results) + try: + client = TestClient(self.app) + client.post("/api/streaming/evaluate-sessions", json={"golden_session_id": "golden", "eval_set_id": "e1"}) + finally: + del self.app.state.run_service + [run] = asyncio.run(repos.runs.list()) + assert {c.kwargs.get("run_id") for c in mock_eval.await_args_list} == {str(run.run_id)} + # --------------------------------------------------------------------------- # POST /api/streaming/prepare-evaluation