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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **`Step.agent_id: str | None = None`** — Phase 1 of the MAST multi-agent alignment scoped in
`docs/concepts/multi-agent-failures.md`. Records which agent produced a step; optional,
`None` by default, zero behavior change for existing single-agent callers.
`triage.observability.otel_ingest.trajectory_from_spans()` now populates it from a span's
`gen_ai.agent.id` (preferred) or `gen_ai.agent.name` attribute when present — the same
extraction pattern already used for `tool_called`/`metadata["http_status"]`.

The finding worth calling out: `RulesClassifier`'s `LOOP_DETECTED` needed **zero changes**
to catch a step repeated across two different agents (MAST's "Step Repetition"), not the
small extension originally estimated. `_is_loop_window()` was already agent-identity-agnostic
— it only ever compared `tool_called`/`tool_input`, because there was no agent field to look
at — so adding `Step.agent_id` made the existing single-agent matching logic correct for the
multi-agent case for free. Pinned by
`test_loop_detected_across_different_agent_ids` in `tests/test_classifier_rules.py`, and
documented as deliberate (not an oversight to "fix" later) in `rules.py`'s comments and
`RulesClassifier`'s docstring — see `CLAUDE.md`'s design-decisions section for the full
rationale on why same-agent-only matching would be the wrong default.

Also wires `agent_id` through `triage/suspension.py`'s `serialize_run`/`deserialize_run` so
a suspended run's trajectory round-trips it correctly (`triage/checkpoint/base.py`'s
checkpoint serialization has a pre-existing, separate gap here — it already didn't round-trip
`idempotent`/`partial` either, before this change — left as-is since fixing it is unrelated
to this phase).

- **`docs/concepts/multi-agent-failures.md`** — a design proposal, not implemented, scoping
what it would take to detect multi-agent failures against the published
[MAST taxonomy](https://github.com/multi-agent-systems-failure-taxonomy/MAST) (14 failure
Expand Down
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ No framework imports anywhere in `triage/` core — adapters live in `triage/ada

```
triage/ — importable package
taxonomy.py — FailureType enum (9 members), Step (with idempotent field), FailureContext
taxonomy.py — FailureType enum (9 members), Step (with idempotent, agent_id fields), FailureContext
trajectory.py — Trajectory class (append / replay_from / last_n_steps)
checkpoint/ — Checkpoint package
__init__.py — re-exports Checkpoint, CheckpointStore, InMemoryCheckpointStore, make_checkpoint
Expand Down Expand Up @@ -126,7 +126,7 @@ without a labeled corpus.

## RulesClassifier rule priority

1. LOOP_DETECTED — last `loop_window` steps (default 3, configurable): identical `tool_called` + canonical `tool_input`
1. LOOP_DETECTED — last `loop_window` steps (default 3, configurable): identical `tool_called` + canonical `tool_input`, regardless of `Step.agent_id` — catches a step repeated across two different agents, not just one agent looping on itself (MAST "Step Repetition")
2. WRONG_TOOL_CALLED — error matches tool-not-found patterns across OpenAI/Anthropic/generic SDKs, OR `metadata["json_rpc_code"] == -32601`
3. SCHEMA_MISMATCH — error matches `validation error|json.*parse|jsondecodeerror|invalid json|unexpected token`, OR `metadata["json_rpc_code"] == -32700`
4. EXTERNAL_FAULT — error contains `\b(429|500|502|503)\b` (word-boundary, avoids false positives), OR `metadata["http_status"] in (429, 500, 502, 503)`, OR `metadata["json_rpc_code"] == -32603`
Expand Down Expand Up @@ -321,6 +321,20 @@ attributes between spec revisions (see the module docstring for the exact key sp
`Step.metadata["http_status"]` is extracted from the stable HTTP semconv and feeds directly into
`RulesClassifier`'s structured-error-code matching above — a real HTTP client span with a
429/500/502/503/408/504 status classifies correctly with zero code from the caller.
`Step.agent_id` is extracted from a span's `gen_ai.agent.id` (preferred) or `gen_ai.agent.name`
attribute the same way, feeding MAST-alignment phase 1 below.

**`Step.agent_id` and `LOOP_DETECTED`'s agent-agnostic matching (MAST phase 1).** `Step.agent_id:
str | None = None` records which agent produced a step — optional, `None` by default, zero
behavior change for single-agent callers. `RulesClassifier`'s `_is_loop_window()` matches purely
on `tool_called`/`tool_input` equality and has never looked at agent identity, so it already
catches a step repeated across two different `agent_id`s (MAST's "Step Repetition" — see
`docs/concepts/multi-agent-failures.md`) with zero changes to the matching logic itself; adding
the field was the entire change. Don't "fix" this into requiring same-`agent_id` matching by
default — that would silently break single-agent loop detection for any caller who happens to
tag steps with an agent id, and the whole point of MAST phase 1 is that a loop spanning a
handoff is still a loop. Regression guard:
`tests/test_classifier_rules.py::test_loop_detected_across_different_agent_ids`.

## Classifier accuracy measurement

Expand Down
14 changes: 7 additions & 7 deletions docs/concepts/multi-agent-failures.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Multi-Agent Failures: MAST Scoping

**Status: a design proposal, not yet implemented.** Nothing in this document has shipped. It exists to answer one question concretely before any code is written: what would it actually take for `triage` to detect multi-agent failure modes, and how much of that can be done honestly with the same rigor the rest of this project holds itself to — measured, not assumed, and never a new `FailureType` without real disambiguation logic behind it.
**Status: phase 1 shipped (see "Recommended phasing" below); phases 2-3 are still a design proposal, not implemented.** This document exists to answer one question concretely before writing code: what would it actually take for `triage` to detect multi-agent failure modes, and how much of that can be done honestly with the same rigor the rest of this project holds itself to — measured, not assumed, and never a new `FailureType` without real disambiguation logic behind it.

## Why this exists

Expand Down Expand Up @@ -45,7 +45,7 @@ Single-agent failure modes — wrong tool, bad schema, a timeout — are increas

Before any classification question, there's a data-model question that blocks all of it. `Step`/`Trajectory` today represent one flat, single-actor sequence — there is no field recording *which agent* produced a given step. None of MAST's category 2 (inter-agent) failure modes are even representable in triage's current model, independent of how good a classifier gets — "ignored other agent's input" requires knowing there *was* another agent and what it said.

The minimal, additive fix: `Step.agent_id: str | None = None`, the same low-risk pattern `Step.metadata` already established — an optional field with zero effect on any existing caller. It connects directly to work already shipped: the OTel GenAI semantic conventions already define `gen_ai.agent.id`/`gen_ai.agent.name` on `invoke_agent` spans (verified directly against the spec), so `triage.observability.otel_ingest.trajectory_from_spans()` could capture agent identity the same way it already captures `tool_called`/`http_status` — real frameworks that emit per-agent spans would populate this for free, the same story as the HTTP-status connection to `RulesClassifier`'s structured-code matching.
**✅ Done (phase 1):** `Step.agent_id: str | None = None`, the same low-risk pattern `Step.metadata` already established — an optional field with zero effect on any existing caller. It connects directly to work already shipped: the OTel GenAI semantic conventions already define `gen_ai.agent.id`/`gen_ai.agent.name` on `invoke_agent` spans (verified directly against the spec), so `triage.observability.otel_ingest.trajectory_from_spans()` now captures agent identity the same way it already captured `tool_called`/`http_status` — real frameworks that emit per-agent spans populate this for free, the same story as the HTTP-status connection to `RulesClassifier`'s structured-code matching.

## Mapping the 14 modes

Expand All @@ -55,7 +55,7 @@ Honesty matters more here than coverage. Three groups, by how they'd actually ge

| MAST mode | Existing mapping |
|---|---|
| 1.3 Step Repetition | `LOOP_DETECTED` — triage already detects this for one agent (`_is_loop_window()` in `rules.py`). Once `agent_id` exists, the same mechanism extends to catch a step repeated *across* agents — a small, concrete code change, not a new concept. |
| 1.3 Step Repetition | `LOOP_DETECTED` — ✅ done (phase 1, see below): `_is_loop_window()` in `rules.py` was already agent-identity-agnostic, so adding `Step.agent_id` made it catch a step repeated *across* agents with zero matching-logic changes — not the "small code change" this doc originally estimated, but no change at all. |
| 3.1 Premature Termination | `PLAN_INCOMPLETE` — CLAUDE.md's own taxonomy table already defines this as "agent declared success but not all required sub-goals were completed," which *is* MAST's 3.1 definition, just framed for one agent. Needs a docs update connecting the two, not a new type. |

Finding two direct hits here — including one where triage already has *working, tested code* for the single-agent case — is the strongest evidence this taxonomy is worth aligning with rather than inventing categories from scratch.
Expand All @@ -81,10 +81,10 @@ CLAUDE.md documents exactly this failure mode already happening once: `HALLUCINA

## Recommended phasing

1. **`Step.agent_id: str | None = None`** — pure additive field, zero behavior change for existing callers. Extend `otel_ingest.trajectory_from_spans()` to populate it from `gen_ai.agent.id`/`gen_ai.agent.name` when present. Extend `LOOP_DETECTED`'s matching to also catch identical steps across different `agent_id`s. This alone ships something real: cross-agent loop detection, using code that already exists and is already tested.
2. **Prototype 3.3 (verification mismatch) and 2.1 (conversation reset)** as candidate `RulesClassifier` rules, validated against a constructed corpus of real multi-agent framework traces (AutoGen, CrewAI, LangGraph multi-agent) — same sourcing discipline as `tests/data/error_corpus_*.json`, not synthetic examples built to make the rule look good.
3. **Only after (1) and (2) ship and are measured:** extend `LLMClassifier`'s prompt to recognize the ten semantic-only modes, evaluate it against real multi-agent traces, and only then consider whether any of them earn a stable `FailureType` member — each one needs its own answer to "how does a classifier actually tell this apart from the others," not just a taxonomy citation.
1. ✅ **Done.** `Step.agent_id: str | None = None` — pure additive field, zero behavior change for existing callers. `otel_ingest.trajectory_from_spans()` populates it from `gen_ai.agent.id`/`gen_ai.agent.name` when present (`id` preferred when both are set). Cross-agent loop detection ships too — but the honest correction to this doc's own earlier estimate: it needed **zero changes** to `_is_loop_window()`'s matching logic, not an extension of it. That function was already agent-agnostic (it only ever compared `tool_called`/`tool_input`, never looked at agent identity, because the field didn't exist) — adding `agent_id` to `Step` made the existing single-agent test suite's matching logic correct for the multi-agent case for free. Pinned by `test_loop_detected_across_different_agent_ids` in `tests/test_classifier_rules.py`, so a future change can't accidentally narrow it back to same-agent-only without a test failing. `RulesClassifier`'s docstring and the `classify()` comment above the loop check now document this as deliberate, not unnoticed.
2. **Not started.** Prototype 3.3 (verification mismatch) and 2.1 (conversation reset) as candidate `RulesClassifier` rules, validated against a constructed corpus of real multi-agent framework traces (AutoGen, CrewAI, LangGraph multi-agent) — same sourcing discipline as `tests/data/error_corpus_*.json`, not synthetic examples built to make the rule look good.
3. **Not started, blocked on (2).** Only after (1) and (2) ship and are measured: extend `LLMClassifier`'s prompt to recognize the ten semantic-only modes, evaluate it against real multi-agent traces, and only then consider whether any of them earn a stable `FailureType` member — each one needs its own answer to "how does a classifier actually tell this apart from the others," not just a taxonomy citation.

## Non-goals

This is not a plan to add 14 new `FailureType` members. It is not a plan to build a multi-agent orchestration framework — triage still wraps whatever callable you give it, single- or multi-agent. It is not started — `git blame` on this file should show it arriving with no corresponding change to `taxonomy.py`.
This is not a plan to add 14 new `FailureType` members. It is not a plan to build a multi-agent orchestration framework — triage still wraps whatever callable you give it, single- or multi-agent. Phase 1 is done (see above); phases 2 and 3 are not.
6 changes: 4 additions & 2 deletions docs/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,11 @@ The conditional-import fallback pattern (`Tracer = Any`, `_otel_trace = None`) d

## Multi-agent systems

`Step`/`Trajectory` represent one flat, single-actor sequence — there is no field recording *which agent* produced a given step. Failures specific to multi-agent coordination (a handoff losing context, one agent ignoring another's output, a verifier claiming success on a broken result) aren't representable in triage's data model today, independent of classifier sophistication — you can't detect "agent A ignored agent B's input" without knowing there were two agents and what each said.
`Step.agent_id: str | None = None` records which agent produced a given step (optional, `None` by default — no effect on single-agent callers), and `triage.observability.otel_ingest.trajectory_from_spans()` populates it automatically from a span's `gen_ai.agent.id`/`gen_ai.agent.name` attribute when present. `RulesClassifier`'s `LOOP_DETECTED` already catches a step repeated across two different agents, not just the same agent looping on itself — its matching was always agent-identity-agnostic, so this needed no new matching logic once the field existed.

See [`docs/concepts/multi-agent-failures.md`](concepts/multi-agent-failures.md) for a full scoping against the published [MAST taxonomy](https://github.com/multi-agent-systems-failure-taxonomy/MAST) (14 failure modes, 3 categories) — what maps onto `triage`'s *existing* `FailureType`s with no new code, what's a plausible new `RulesClassifier` rule worth measuring, and what's semantic-only and would need an `LLMClassifier` prompt extension. This is a design proposal, not implemented — `Step.agent_id` doesn't exist yet.
That's as far as multi-agent detection goes today. Failures that need semantic understanding of what multiple agents actually said to each other (a handoff losing context, one agent ignoring another's output, a verifier claiming success on a broken result) aren't detectable yet — RulesClassifier has no way to reach them, and LLMClassifier's prompt doesn't yet know to look for them.

See [`docs/concepts/multi-agent-failures.md`](concepts/multi-agent-failures.md) for the full scoping against the published [MAST taxonomy](https://github.com/multi-agent-systems-failure-taxonomy/MAST) (14 failure modes, 3 categories): what's shipped (phase 1, above), what's a plausible new `RulesClassifier` rule worth measuring next, and what's semantic-only and would need an `LLMClassifier` prompt extension — deliberately not built yet.

## Comparison with framework-native error handling

Expand Down
17 changes: 17 additions & 0 deletions tests/test_classifier_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def make_step(
llm_output: str | None = None,
exception_type: str | None = None,
metadata: dict | None = None,
agent_id: str | None = None,
) -> Step:
return Step(
index=index,
Expand All @@ -25,6 +26,7 @@ def make_step(
llm_output=llm_output,
exception_type=exception_type,
metadata=metadata or {},
agent_id=agent_id,
)


Expand All @@ -48,6 +50,21 @@ def test_loop_detected():
assert RulesClassifier().classify(t, "task") == FailureType.LOOP_DETECTED


def test_loop_detected_across_different_agent_ids():
"""MAST's "Step Repetition" (see docs/concepts/multi-agent-failures.md):
a handoff causes a second agent to unnecessarily redo work a first agent
already completed. Loop matching is deliberately agent_id-agnostic —
tool_called/tool_input equality alone is enough, regardless of who made
the call — so this fires with zero code change beyond Step.agent_id
existing as a field. Pins that as a verified fact, not an assumption."""
t = traj(
make_step(0, tool_called="search", tool_input={"q": "hello"}, agent_id="agent-a"),
make_step(1, tool_called="search", tool_input={"q": "hello"}, agent_id="agent-b"),
make_step(2, tool_called="search", tool_input={"q": "hello"}, agent_id="agent-a"),
)
assert RulesClassifier().classify(t, "task") == FailureType.LOOP_DETECTED


def test_loop_not_detected_two_steps():
t = traj(
make_step(0, tool_called="search", tool_input={"q": "hello"}),
Expand Down
66 changes: 66 additions & 0 deletions tests/test_observability_otel_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,72 @@ def test_spans_sorted_by_end_time_regardless_of_input_order():
assert [s.tool_called for s in traj.steps] == ["first", "second"]


# ── agent identity -> Step.agent_id (feeds MAST-alignment phase 1) ──────────


@pytestmark_otel
def test_agent_id_extracted_from_gen_ai_agent_id():
from triage.observability.otel_ingest import trajectory_from_spans

tracer, exporter = _make_exporter()
with tracer.start_as_current_span(
"invoke_agent Researcher",
attributes={"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.id": "agent-123"},
):
pass

traj = trajectory_from_spans(exporter.get_finished_spans())
assert traj[0].agent_id == "agent-123"


@pytestmark_otel
def test_agent_id_falls_back_to_gen_ai_agent_name():
from triage.observability.otel_ingest import trajectory_from_spans

tracer, exporter = _make_exporter()
with tracer.start_as_current_span(
"invoke_agent Researcher",
attributes={"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": "Researcher"},
):
pass

traj = trajectory_from_spans(exporter.get_finished_spans())
assert traj[0].agent_id == "Researcher"


@pytestmark_otel
def test_agent_id_prefers_id_over_name_when_both_present():
from triage.observability.otel_ingest import trajectory_from_spans

tracer, exporter = _make_exporter()
with tracer.start_as_current_span(
"invoke_agent Researcher",
attributes={
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.id": "agent-123",
"gen_ai.agent.name": "Researcher",
},
):
pass

traj = trajectory_from_spans(exporter.get_finished_spans())
assert traj[0].agent_id == "agent-123"


@pytestmark_otel
def test_agent_id_absent_when_span_carries_no_agent_attribute():
from triage.observability.otel_ingest import trajectory_from_spans

tracer, exporter = _make_exporter()
with tracer.start_as_current_span(
"execute_tool search", attributes={"gen_ai.tool.name": "search"}
):
pass

traj = trajectory_from_spans(exporter.get_finished_spans())
assert traj[0].agent_id is None


# ── action field ──────────────────────────────────────────────────────────────


Expand Down
Loading
Loading